regscale-cli 6.25.1.0__py3-none-any.whl → 6.26.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (80) hide show
  1. regscale/_version.py +1 -1
  2. regscale/airflow/hierarchy.py +2 -2
  3. regscale/core/app/application.py +18 -3
  4. regscale/core/app/internal/login.py +0 -1
  5. regscale/core/app/utils/catalog_utils/common.py +1 -1
  6. regscale/integrations/commercial/sicura/api.py +14 -13
  7. regscale/integrations/commercial/sicura/commands.py +8 -2
  8. regscale/integrations/commercial/sicura/scanner.py +49 -39
  9. regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
  10. regscale/integrations/commercial/wizv2/click.py +26 -26
  11. regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
  12. regscale/integrations/commercial/wizv2/scanner.py +3 -3
  13. regscale/integrations/compliance_integration.py +67 -2
  14. regscale/integrations/control_matcher.py +358 -0
  15. regscale/integrations/milestone_manager.py +291 -0
  16. regscale/integrations/public/__init__.py +1 -0
  17. regscale/integrations/public/cci_importer.py +37 -38
  18. regscale/integrations/public/fedramp/click.py +60 -2
  19. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  20. regscale/integrations/scanner_integration.py +150 -96
  21. regscale/models/integration_models/cisa_kev_data.json +154 -4
  22. regscale/models/integration_models/nexpose.py +36 -10
  23. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  24. regscale/models/locking.py +12 -8
  25. regscale/models/platform.py +1 -2
  26. regscale/models/regscale_models/control_implementation.py +46 -21
  27. regscale/models/regscale_models/issue.py +256 -94
  28. regscale/models/regscale_models/milestone.py +1 -1
  29. regscale/models/regscale_models/regscale_model.py +6 -1
  30. regscale/templates/__init__.py +0 -0
  31. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
  32. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +80 -33
  33. tests/regscale/integrations/commercial/__init__.py +0 -0
  34. tests/regscale/integrations/commercial/conftest.py +28 -0
  35. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  36. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  37. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  38. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  39. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  40. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  41. tests/regscale/integrations/commercial/test_burp.py +48 -0
  42. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  43. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  44. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  45. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  46. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  47. tests/regscale/integrations/commercial/test_jira.py +1814 -0
  48. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  49. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  50. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  51. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  52. tests/regscale/integrations/commercial/test_snow.py +423 -0
  53. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  54. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  55. tests/regscale/integrations/commercial/test_stig.py +33 -0
  56. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  57. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  58. tests/regscale/integrations/commercial/test_wiz.py +1469 -0
  59. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  60. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  61. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  62. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  63. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  64. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
  65. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  66. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  67. tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
  68. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  69. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
  70. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
  71. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  72. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  73. tests/regscale/integrations/test_control_matcher.py +1314 -0
  74. tests/regscale/integrations/test_control_matching.py +155 -0
  75. tests/regscale/integrations/test_milestone_manager.py +408 -0
  76. tests/regscale/models/test_issue.py +378 -1
  77. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
  78. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
  79. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
  80. {regscale_cli-6.25.1.0.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1314 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Unit and integration tests for control_matcher module
5
+
6
+ This module provides comprehensive test coverage for the ControlMatcher class,
7
+ including control ID parsing, catalog searches, implementation matching, and caching.
8
+ """
9
+
10
+ import logging
11
+ from typing import Dict, List, Optional, Tuple
12
+ from unittest.mock import MagicMock, Mock, patch
13
+
14
+ import pytest
15
+
16
+ from regscale.core.app.api import Api
17
+ from regscale.core.app.application import Application
18
+ from regscale.integrations.control_matcher import ControlMatcher
19
+ from regscale.models.regscale_models.control_implementation import ControlImplementation
20
+ from regscale.models.regscale_models.security_control import SecurityControl
21
+
22
+
23
+ class TestControlMatcherInit:
24
+ """Test cases for ControlMatcher initialization"""
25
+
26
+ @patch("regscale.integrations.control_matcher.Api")
27
+ @patch("regscale.integrations.control_matcher.Application")
28
+ def test_init_with_no_app(self, mock_app_class, mock_api_class):
29
+ """Test ControlMatcher initialization without providing an app"""
30
+ mock_app = MagicMock(spec=Application)
31
+ mock_app_class.return_value = mock_app
32
+ mock_api = MagicMock(spec=Api)
33
+ mock_api_class.return_value = mock_api
34
+
35
+ matcher = ControlMatcher()
36
+
37
+ mock_app_class.assert_called_once()
38
+ mock_api_class.assert_called_once()
39
+ assert matcher.app == mock_app
40
+ assert matcher.api == mock_api
41
+ assert matcher._catalog_cache == {}
42
+ assert matcher._control_impl_cache == {}
43
+
44
+ @patch("regscale.integrations.control_matcher.Api")
45
+ @patch("regscale.integrations.control_matcher.Application")
46
+ def test_init_with_app(self, mock_app_class, mock_api_class):
47
+ """Test ControlMatcher initialization with an app instance"""
48
+ # Create a mock app that is truthy (has a return value)
49
+ mock_app = Mock(spec=Application)
50
+ # Set a non-None return value to make the mock truthy
51
+ mock_app.return_value = MagicMock()
52
+
53
+ mock_api = MagicMock(spec=Api)
54
+ mock_api_class.return_value = mock_api
55
+
56
+ matcher = ControlMatcher(app=mock_app)
57
+
58
+ # When app is provided and is truthy, it should be used
59
+ assert matcher.app == mock_app
60
+ assert matcher.api == mock_api
61
+ assert matcher._catalog_cache == {}
62
+ assert matcher._control_impl_cache == {}
63
+
64
+
65
+ class TestControlMatcherParseControlId:
66
+ """Test cases for parse_control_id method"""
67
+
68
+ @patch("regscale.integrations.control_matcher.Api")
69
+ @patch("regscale.integrations.control_matcher.Application")
70
+ def test_parse_control_id_none(self, mock_app_class, mock_api_class):
71
+ """Test parsing None control ID returns None"""
72
+ matcher = ControlMatcher()
73
+ result = matcher.parse_control_id(None)
74
+ assert result is None
75
+
76
+ @patch("regscale.integrations.control_matcher.Api")
77
+ @patch("regscale.integrations.control_matcher.Application")
78
+ def test_parse_control_id_empty_string(self, mock_app_class, mock_api_class):
79
+ """Test parsing empty string returns None"""
80
+ matcher = ControlMatcher()
81
+ result = matcher.parse_control_id("")
82
+ assert result is None
83
+
84
+ @patch("regscale.integrations.control_matcher.Api")
85
+ @patch("regscale.integrations.control_matcher.Application")
86
+ def test_parse_control_id_whitespace(self, mock_app_class, mock_api_class):
87
+ """Test parsing whitespace-only string returns None"""
88
+ matcher = ControlMatcher()
89
+ result = matcher.parse_control_id(" ")
90
+ assert result is None
91
+
92
+ @patch("regscale.integrations.control_matcher.Api")
93
+ @patch("regscale.integrations.control_matcher.Application")
94
+ def test_parse_control_id_basic_format(self, mock_app_class, mock_api_class):
95
+ """Test parsing basic NIST control ID format"""
96
+ matcher = ControlMatcher()
97
+ test_cases = [
98
+ ("AC-1", "AC-1"),
99
+ ("ac-1", "AC-1"),
100
+ ("AC-10", "AC-10"),
101
+ ("SI-2", "SI-2"),
102
+ ("CM-6", "CM-6"),
103
+ ]
104
+
105
+ for input_id, expected in test_cases:
106
+ result = matcher.parse_control_id(input_id)
107
+ assert result == expected, f"Failed for input {input_id}"
108
+
109
+ @patch("regscale.integrations.control_matcher.Api")
110
+ @patch("regscale.integrations.control_matcher.Application")
111
+ def test_parse_control_id_with_parentheses(self, mock_app_class, mock_api_class):
112
+ """Test parsing control ID with parentheses converts to dots"""
113
+ matcher = ControlMatcher()
114
+ test_cases = [
115
+ ("AC-1(1)", "AC-1.1"),
116
+ ("ac-2(3)", "AC-2.3"),
117
+ ("SI-4(10)", "SI-4.10"),
118
+ ("CM-6(1)", "CM-6.1"),
119
+ ]
120
+
121
+ for input_id, expected in test_cases:
122
+ result = matcher.parse_control_id(input_id)
123
+ assert result == expected, f"Failed for input {input_id}"
124
+
125
+ @patch("regscale.integrations.control_matcher.Api")
126
+ @patch("regscale.integrations.control_matcher.Application")
127
+ def test_parse_control_id_with_dots(self, mock_app_class, mock_api_class):
128
+ """Test parsing control ID already with dot notation"""
129
+ matcher = ControlMatcher()
130
+ test_cases = [
131
+ ("AC-1.1", "AC-1.1"),
132
+ ("ac-2.5", "AC-2.5"),
133
+ ("SI-4.12", "SI-4.12"),
134
+ ]
135
+
136
+ for input_id, expected in test_cases:
137
+ result = matcher.parse_control_id(input_id)
138
+ assert result == expected, f"Failed for input {input_id}"
139
+
140
+ @patch("regscale.integrations.control_matcher.Api")
141
+ @patch("regscale.integrations.control_matcher.Application")
142
+ def test_parse_control_id_with_text(self, mock_app_class, mock_api_class):
143
+ """Test parsing control ID with descriptive text"""
144
+ matcher = ControlMatcher()
145
+ test_cases = [
146
+ ("Access Control AC-1", "AC-1"),
147
+ ("AC-1 Access Control Policy", "AC-1"),
148
+ ("NIST Control AC-2 Account Management", "AC-2"),
149
+ ("System Monitoring SI-4(5)", "SI-4.5"),
150
+ ]
151
+
152
+ for input_id, expected in test_cases:
153
+ result = matcher.parse_control_id(input_id)
154
+ assert result == expected, f"Failed for input {input_id}"
155
+
156
+ @patch("regscale.integrations.control_matcher.Api")
157
+ @patch("regscale.integrations.control_matcher.Application")
158
+ def test_parse_control_id_multiple_controls_returns_first(self, mock_app_class, mock_api_class):
159
+ """Test parsing string with multiple controls returns first one"""
160
+ matcher = ControlMatcher()
161
+ test_cases = [
162
+ ("AC-1, AC-2", "AC-1"),
163
+ ("SI-2, SI-4, CM-6", "SI-2"),
164
+ ("AC-1(1), AC-1(2)", "AC-1.1"),
165
+ ]
166
+
167
+ for input_id, expected in test_cases:
168
+ result = matcher.parse_control_id(input_id)
169
+ assert result == expected, f"Failed for input {input_id}"
170
+
171
+ @patch("regscale.integrations.control_matcher.Api")
172
+ @patch("regscale.integrations.control_matcher.Application")
173
+ def test_parse_control_id_three_letter_family(self, mock_app_class, mock_api_class):
174
+ """Test parsing control IDs with three-letter family codes"""
175
+ matcher = ControlMatcher()
176
+ test_cases = [
177
+ ("PTA-1", "PTA-1"),
178
+ ("SAR-10", "SAR-10"),
179
+ ("PRM-3(2)", "PRM-3.2"),
180
+ ]
181
+
182
+ for input_id, expected in test_cases:
183
+ result = matcher.parse_control_id(input_id)
184
+ assert result == expected, f"Failed for input {input_id}"
185
+
186
+ @patch("regscale.integrations.control_matcher.Api")
187
+ @patch("regscale.integrations.control_matcher.Application")
188
+ def test_parse_control_id_no_match(self, mock_app_class, mock_api_class):
189
+ """Test parsing invalid control ID format returns None"""
190
+ matcher = ControlMatcher()
191
+ test_cases = [
192
+ "No control here",
193
+ "12345",
194
+ "A-1", # Too short family code (single letter)
195
+ "AC", # Missing number
196
+ "Control without ID",
197
+ ]
198
+
199
+ for input_id in test_cases:
200
+ result = matcher.parse_control_id(input_id)
201
+ assert result is None, f"Expected None for input {input_id}, got {result}"
202
+
203
+
204
+ class TestControlMatcherFindControlInCatalog:
205
+ """Test cases for find_control_in_catalog method"""
206
+
207
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
208
+ @patch("regscale.integrations.control_matcher.Api")
209
+ @patch("regscale.integrations.control_matcher.Application")
210
+ def test_find_control_exact_match(self, mock_app_class, mock_api_class, mock_get_controls):
211
+ """Test finding control with exact match"""
212
+ matcher = ControlMatcher()
213
+
214
+ # Create mock controls
215
+ mock_control1 = MagicMock(spec=SecurityControl)
216
+ mock_control1.controlId = "AC-1"
217
+ mock_control1.id = 100
218
+
219
+ mock_control2 = MagicMock(spec=SecurityControl)
220
+ mock_control2.controlId = "AC-2"
221
+ mock_control2.id = 101
222
+
223
+ mock_get_controls.return_value = [mock_control1, mock_control2]
224
+
225
+ result = matcher.find_control_in_catalog("AC-1", 1)
226
+
227
+ assert result == mock_control1
228
+ mock_get_controls.assert_called_once_with(1)
229
+
230
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
231
+ @patch("regscale.integrations.control_matcher.Api")
232
+ @patch("regscale.integrations.control_matcher.Application")
233
+ def test_find_control_normalized_match(self, mock_app_class, mock_api_class, mock_get_controls):
234
+ """Test finding control with normalized match when exact match fails"""
235
+ matcher = ControlMatcher()
236
+
237
+ # Create mock controls with parentheses notation
238
+ mock_control1 = MagicMock(spec=SecurityControl)
239
+ mock_control1.controlId = "AC-1(1)"
240
+ mock_control1.id = 100
241
+
242
+ mock_control2 = MagicMock(spec=SecurityControl)
243
+ mock_control2.controlId = "AC-2"
244
+ mock_control2.id = 101
245
+
246
+ mock_get_controls.return_value = [mock_control1, mock_control2]
247
+
248
+ # Search using dot notation
249
+ result = matcher.find_control_in_catalog("AC-1.1", 1)
250
+
251
+ assert result == mock_control1
252
+ mock_get_controls.assert_called_once_with(1)
253
+
254
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
255
+ @patch("regscale.integrations.control_matcher.Api")
256
+ @patch("regscale.integrations.control_matcher.Application")
257
+ def test_find_control_not_found(self, mock_app_class, mock_api_class, mock_get_controls):
258
+ """Test finding control that doesn't exist returns None"""
259
+ matcher = ControlMatcher()
260
+
261
+ mock_control1 = MagicMock(spec=SecurityControl)
262
+ mock_control1.controlId = "AC-1"
263
+ mock_control1.id = 100
264
+
265
+ mock_get_controls.return_value = [mock_control1]
266
+
267
+ result = matcher.find_control_in_catalog("SI-4", 1)
268
+
269
+ assert result is None
270
+ mock_get_controls.assert_called_once_with(1)
271
+
272
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
273
+ @patch("regscale.integrations.control_matcher.Api")
274
+ @patch("regscale.integrations.control_matcher.Application")
275
+ def test_find_control_empty_catalog(self, mock_app_class, mock_api_class, mock_get_controls):
276
+ """Test finding control in empty catalog returns None"""
277
+ matcher = ControlMatcher()
278
+ mock_get_controls.return_value = []
279
+
280
+ result = matcher.find_control_in_catalog("AC-1", 1)
281
+
282
+ assert result is None
283
+ mock_get_controls.assert_called_once_with(1)
284
+
285
+
286
+ class TestControlMatcherFindControlImplementation:
287
+ """Test cases for find_control_implementation method"""
288
+
289
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
290
+ @patch("regscale.integrations.control_matcher.Api")
291
+ @patch("regscale.integrations.control_matcher.Application")
292
+ def test_find_implementation_by_label(self, mock_app_class, mock_api_class, mock_get_impls):
293
+ """Test finding implementation by control label"""
294
+ matcher = ControlMatcher()
295
+
296
+ # Create mock implementations
297
+ mock_impl1 = MagicMock(spec=ControlImplementation)
298
+ mock_impl1.id = 200
299
+ mock_impl1.controlID = 100
300
+
301
+ mock_impl2 = MagicMock(spec=ControlImplementation)
302
+ mock_impl2.id = 201
303
+ mock_impl2.controlID = 101
304
+
305
+ mock_get_impls.return_value = {
306
+ "AC-1": mock_impl1,
307
+ "AC-2": mock_impl2,
308
+ }
309
+
310
+ result = matcher.find_control_implementation("AC-1", 50, "securityplans")
311
+
312
+ assert result == mock_impl1
313
+ mock_get_impls.assert_called_once_with(50, "securityplans")
314
+
315
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
316
+ @patch("regscale.integrations.control_matcher.Api")
317
+ @patch("regscale.integrations.control_matcher.Application")
318
+ def test_find_implementation_case_insensitive(self, mock_app_class, mock_api_class, mock_get_impls):
319
+ """Test finding implementation with case-insensitive matching"""
320
+ matcher = ControlMatcher()
321
+
322
+ mock_impl1 = MagicMock(spec=ControlImplementation)
323
+ mock_impl1.id = 200
324
+ mock_impl1.controlID = 100
325
+
326
+ mock_get_impls.return_value = {
327
+ "ac-1": mock_impl1, # lowercase in dict
328
+ }
329
+
330
+ result = matcher.find_control_implementation("AC-1", 50)
331
+
332
+ assert result == mock_impl1
333
+
334
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_in_catalog")
335
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
336
+ @patch("regscale.integrations.control_matcher.Api")
337
+ @patch("regscale.integrations.control_matcher.Application")
338
+ def test_find_implementation_via_catalog(self, mock_app_class, mock_api_class, mock_get_impls, mock_find_control):
339
+ """Test finding implementation via catalog when label match fails"""
340
+ matcher = ControlMatcher()
341
+
342
+ # No label match
343
+ mock_impl1 = MagicMock(spec=ControlImplementation)
344
+ mock_impl1.id = 200
345
+ mock_impl1.controlID = 100
346
+
347
+ mock_get_impls.return_value = {
348
+ "SI-2": mock_impl1, # Different control
349
+ }
350
+
351
+ # But catalog returns a control
352
+ mock_control = MagicMock(spec=SecurityControl)
353
+ mock_control.id = 100
354
+ mock_control.controlId = "AC-1"
355
+ mock_find_control.return_value = mock_control
356
+
357
+ result = matcher.find_control_implementation("AC-1", 50, "securityplans", catalog_id=1)
358
+
359
+ assert result == mock_impl1
360
+ mock_find_control.assert_called_once_with("AC-1", 1)
361
+
362
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
363
+ @patch("regscale.integrations.control_matcher.Api")
364
+ @patch("regscale.integrations.control_matcher.Application")
365
+ def test_find_implementation_invalid_control_id(self, mock_app_class, mock_api_class, mock_get_impls):
366
+ """Test finding implementation with invalid control ID returns None"""
367
+ matcher = ControlMatcher()
368
+ mock_get_impls.return_value = {}
369
+
370
+ with patch("regscale.integrations.control_matcher.logger") as mock_logger:
371
+ result = matcher.find_control_implementation("Invalid", 50)
372
+
373
+ assert result is None
374
+ mock_logger.warning.assert_called_once()
375
+
376
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
377
+ @patch("regscale.integrations.control_matcher.Api")
378
+ @patch("regscale.integrations.control_matcher.Application")
379
+ def test_find_implementation_not_found(self, mock_app_class, mock_api_class, mock_get_impls):
380
+ """Test finding implementation that doesn't exist returns None"""
381
+ matcher = ControlMatcher()
382
+ mock_get_impls.return_value = {
383
+ "SI-2": MagicMock(spec=ControlImplementation),
384
+ }
385
+
386
+ result = matcher.find_control_implementation("AC-1", 50)
387
+
388
+ assert result is None
389
+
390
+
391
+ class TestControlMatcherMatchControlsToImplementations:
392
+ """Test cases for match_controls_to_implementations method"""
393
+
394
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
395
+ @patch("regscale.integrations.control_matcher.Api")
396
+ @patch("regscale.integrations.control_matcher.Application")
397
+ def test_match_multiple_controls(self, mock_app_class, mock_api_class, mock_find_impl):
398
+ """Test matching multiple control IDs to implementations"""
399
+ matcher = ControlMatcher()
400
+
401
+ mock_impl1 = MagicMock(spec=ControlImplementation)
402
+ mock_impl1.id = 200
403
+
404
+ mock_impl2 = MagicMock(spec=ControlImplementation)
405
+ mock_impl2.id = 201
406
+
407
+ def find_impl_side_effect(control_id, parent_id, parent_module="securityplans", catalog_id=None):
408
+ if control_id == "AC-1":
409
+ return mock_impl1
410
+ elif control_id == "AC-2":
411
+ return mock_impl2
412
+ return None
413
+
414
+ mock_find_impl.side_effect = find_impl_side_effect
415
+
416
+ control_ids = ["AC-1", "AC-2", "SI-4"]
417
+ result = matcher.match_controls_to_implementations(control_ids, 50)
418
+
419
+ assert len(result) == 3
420
+ assert result["AC-1"] == mock_impl1
421
+ assert result["AC-2"] == mock_impl2
422
+ assert result["SI-4"] is None
423
+ assert mock_find_impl.call_count == 3
424
+
425
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
426
+ @patch("regscale.integrations.control_matcher.Api")
427
+ @patch("regscale.integrations.control_matcher.Application")
428
+ def test_match_empty_list(self, mock_app_class, mock_api_class, mock_find_impl):
429
+ """Test matching empty list returns empty dict"""
430
+ matcher = ControlMatcher()
431
+ result = matcher.match_controls_to_implementations([], 50)
432
+
433
+ assert result == {}
434
+ mock_find_impl.assert_not_called()
435
+
436
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
437
+ @patch("regscale.integrations.control_matcher.Api")
438
+ @patch("regscale.integrations.control_matcher.Application")
439
+ def test_match_with_catalog_id(self, mock_app_class, mock_api_class, mock_find_impl):
440
+ """Test matching controls with catalog ID provided"""
441
+ matcher = ControlMatcher()
442
+ mock_impl = MagicMock(spec=ControlImplementation)
443
+ mock_find_impl.return_value = mock_impl
444
+
445
+ control_ids = ["AC-1"]
446
+ result = matcher.match_controls_to_implementations(control_ids, 50, "securityplans", catalog_id=1)
447
+
448
+ assert result["AC-1"] == mock_impl
449
+ mock_find_impl.assert_called_once_with("AC-1", 50, "securityplans", 1)
450
+
451
+
452
+ class TestControlMatcherGetSecurityPlanControls:
453
+ """Test cases for get_security_plan_controls method"""
454
+
455
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
456
+ @patch("regscale.integrations.control_matcher.Api")
457
+ @patch("regscale.integrations.control_matcher.Application")
458
+ def test_get_security_plan_controls(self, mock_app_class, mock_api_class, mock_get_impls):
459
+ """Test getting all control implementations for a security plan"""
460
+ matcher = ControlMatcher()
461
+
462
+ mock_impl1 = MagicMock(spec=ControlImplementation)
463
+ mock_impl2 = MagicMock(spec=ControlImplementation)
464
+
465
+ expected_dict = {
466
+ "AC-1": mock_impl1,
467
+ "AC-2": mock_impl2,
468
+ }
469
+ mock_get_impls.return_value = expected_dict
470
+
471
+ result = matcher.get_security_plan_controls(50)
472
+
473
+ assert result == expected_dict
474
+ mock_get_impls.assert_called_once_with(50, "securityplans")
475
+
476
+
477
+ class TestControlMatcherFindControlsByPattern:
478
+ """Test cases for find_controls_by_pattern method"""
479
+
480
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
481
+ @patch("regscale.integrations.control_matcher.Api")
482
+ @patch("regscale.integrations.control_matcher.Application")
483
+ def test_find_by_control_id_pattern(self, mock_app_class, mock_api_class, mock_get_controls):
484
+ """Test finding controls by control ID pattern"""
485
+ matcher = ControlMatcher()
486
+
487
+ mock_control1 = MagicMock(spec=SecurityControl)
488
+ mock_control1.controlId = "AC-1"
489
+ mock_control1.title = "Access Control Policy"
490
+
491
+ mock_control2 = MagicMock(spec=SecurityControl)
492
+ mock_control2.controlId = "AC-2"
493
+ mock_control2.title = "Account Management"
494
+
495
+ mock_control3 = MagicMock(spec=SecurityControl)
496
+ mock_control3.controlId = "SI-2"
497
+ mock_control3.title = "Flaw Remediation"
498
+
499
+ mock_get_controls.return_value = [mock_control1, mock_control2, mock_control3]
500
+
501
+ result = matcher.find_controls_by_pattern("^AC-", 1)
502
+
503
+ assert len(result) == 2
504
+ assert mock_control1 in result
505
+ assert mock_control2 in result
506
+ assert mock_control3 not in result
507
+
508
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
509
+ @patch("regscale.integrations.control_matcher.Api")
510
+ @patch("regscale.integrations.control_matcher.Application")
511
+ def test_find_by_title_pattern(self, mock_app_class, mock_api_class, mock_get_controls):
512
+ """Test finding controls by title pattern"""
513
+ matcher = ControlMatcher()
514
+
515
+ mock_control1 = MagicMock(spec=SecurityControl)
516
+ mock_control1.controlId = "AC-1"
517
+ mock_control1.title = "Access Control Policy"
518
+
519
+ mock_control2 = MagicMock(spec=SecurityControl)
520
+ mock_control2.controlId = "AC-2"
521
+ mock_control2.title = "Account Management"
522
+
523
+ mock_control3 = MagicMock(spec=SecurityControl)
524
+ mock_control3.controlId = "SI-2"
525
+ mock_control3.title = "Access Review"
526
+
527
+ mock_get_controls.return_value = [mock_control1, mock_control2, mock_control3]
528
+
529
+ result = matcher.find_controls_by_pattern("Access", 1)
530
+
531
+ assert len(result) == 2
532
+ assert mock_control1 in result
533
+ assert mock_control3 in result
534
+ assert mock_control2 not in result
535
+
536
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
537
+ @patch("regscale.integrations.control_matcher.Api")
538
+ @patch("regscale.integrations.control_matcher.Application")
539
+ def test_find_by_pattern_case_insensitive(self, mock_app_class, mock_api_class, mock_get_controls):
540
+ """Test finding controls with case-insensitive pattern"""
541
+ matcher = ControlMatcher()
542
+
543
+ mock_control1 = MagicMock(spec=SecurityControl)
544
+ mock_control1.controlId = "ac-1"
545
+ mock_control1.title = "access control"
546
+
547
+ mock_get_controls.return_value = [mock_control1]
548
+
549
+ result = matcher.find_controls_by_pattern("ACCESS", 1)
550
+
551
+ assert len(result) == 1
552
+ assert mock_control1 in result
553
+
554
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
555
+ @patch("regscale.integrations.control_matcher.Api")
556
+ @patch("regscale.integrations.control_matcher.Application")
557
+ def test_find_by_pattern_no_matches(self, mock_app_class, mock_api_class, mock_get_controls):
558
+ """Test finding controls with pattern that has no matches"""
559
+ matcher = ControlMatcher()
560
+
561
+ mock_control1 = MagicMock(spec=SecurityControl)
562
+ mock_control1.controlId = "AC-1"
563
+ mock_control1.title = "Access Control"
564
+
565
+ mock_get_controls.return_value = [mock_control1]
566
+
567
+ result = matcher.find_controls_by_pattern("NOMATCH", 1)
568
+
569
+ assert len(result) == 0
570
+
571
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
572
+ @patch("regscale.integrations.control_matcher.Api")
573
+ @patch("regscale.integrations.control_matcher.Application")
574
+ def test_find_by_pattern_none_title(self, mock_app_class, mock_api_class, mock_get_controls):
575
+ """Test finding controls when title is None"""
576
+ matcher = ControlMatcher()
577
+
578
+ mock_control1 = MagicMock(spec=SecurityControl)
579
+ mock_control1.controlId = "AC-1"
580
+ mock_control1.title = None
581
+
582
+ mock_control2 = MagicMock(spec=SecurityControl)
583
+ mock_control2.controlId = "AC-2"
584
+ mock_control2.title = "Account Management"
585
+
586
+ mock_get_controls.return_value = [mock_control1, mock_control2]
587
+
588
+ result = matcher.find_controls_by_pattern("AC-1", 1)
589
+
590
+ assert len(result) == 1
591
+ assert mock_control1 in result
592
+
593
+
594
+ class TestControlMatcherBulkMatchControls:
595
+ """Test cases for bulk_match_controls method"""
596
+
597
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
598
+ @patch("regscale.integrations.control_matcher.Api")
599
+ @patch("regscale.integrations.control_matcher.Application")
600
+ def test_bulk_match_controls(self, mock_app_class, mock_api_class, mock_find_impl):
601
+ """Test bulk matching external IDs to control implementations"""
602
+ matcher = ControlMatcher()
603
+
604
+ mock_impl1 = MagicMock(spec=ControlImplementation)
605
+ mock_impl1.id = 200
606
+
607
+ mock_impl2 = MagicMock(spec=ControlImplementation)
608
+ mock_impl2.id = 201
609
+
610
+ def find_impl_side_effect(control_id, parent_id, parent_module="securityplans", catalog_id=None):
611
+ if control_id == "AC-1":
612
+ return mock_impl1
613
+ elif control_id == "AC-2":
614
+ return mock_impl2
615
+ return None
616
+
617
+ mock_find_impl.side_effect = find_impl_side_effect
618
+
619
+ control_mappings = {
620
+ "ext-001": "AC-1",
621
+ "ext-002": "AC-2",
622
+ "ext-003": "SI-4",
623
+ }
624
+
625
+ result = matcher.bulk_match_controls(control_mappings, 50)
626
+
627
+ assert len(result) == 3
628
+ assert result["ext-001"] == mock_impl1
629
+ assert result["ext-002"] == mock_impl2
630
+ assert result["ext-003"] is None
631
+ assert mock_find_impl.call_count == 3
632
+
633
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
634
+ @patch("regscale.integrations.control_matcher.Api")
635
+ @patch("regscale.integrations.control_matcher.Application")
636
+ def test_bulk_match_empty_dict(self, mock_app_class, mock_api_class, mock_find_impl):
637
+ """Test bulk matching with empty dict returns empty dict"""
638
+ matcher = ControlMatcher()
639
+ result = matcher.bulk_match_controls({}, 50)
640
+
641
+ assert result == {}
642
+ mock_find_impl.assert_not_called()
643
+
644
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
645
+ @patch("regscale.integrations.control_matcher.Api")
646
+ @patch("regscale.integrations.control_matcher.Application")
647
+ def test_bulk_match_with_catalog(self, mock_app_class, mock_api_class, mock_find_impl):
648
+ """Test bulk matching with catalog ID"""
649
+ matcher = ControlMatcher()
650
+ mock_impl = MagicMock(spec=ControlImplementation)
651
+ mock_find_impl.return_value = mock_impl
652
+
653
+ control_mappings = {"ext-001": "AC-1"}
654
+ result = matcher.bulk_match_controls(control_mappings, 50, "securityplans", catalog_id=1)
655
+
656
+ assert result["ext-001"] == mock_impl
657
+ mock_find_impl.assert_called_once_with("AC-1", 50, "securityplans", 1)
658
+
659
+
660
+ class TestControlMatcherGetCatalogControls:
661
+ """Test cases for _get_catalog_controls method"""
662
+
663
+ @patch("regscale.models.regscale_models.security_control.SecurityControl.get_list_by_catalog")
664
+ @patch("regscale.integrations.control_matcher.Api")
665
+ @patch("regscale.integrations.control_matcher.Application")
666
+ def test_get_catalog_controls_first_call(self, mock_app_class, mock_api_class, mock_get_list):
667
+ """Test getting catalog controls on first call (not cached)"""
668
+ matcher = ControlMatcher()
669
+
670
+ mock_control1 = MagicMock(spec=SecurityControl)
671
+ mock_control2 = MagicMock(spec=SecurityControl)
672
+ mock_get_list.return_value = [mock_control1, mock_control2]
673
+
674
+ result = matcher._get_catalog_controls(1)
675
+
676
+ assert len(result) == 2
677
+ assert mock_control1 in result
678
+ assert mock_control2 in result
679
+ mock_get_list.assert_called_once_with(1)
680
+ assert 1 in matcher._catalog_cache
681
+
682
+ @patch("regscale.models.regscale_models.security_control.SecurityControl.get_list_by_catalog")
683
+ @patch("regscale.integrations.control_matcher.Api")
684
+ @patch("regscale.integrations.control_matcher.Application")
685
+ def test_get_catalog_controls_cached(self, mock_app_class, mock_api_class, mock_get_list):
686
+ """Test getting catalog controls from cache on subsequent calls"""
687
+ matcher = ControlMatcher()
688
+
689
+ mock_control1 = MagicMock(spec=SecurityControl)
690
+ mock_control2 = MagicMock(spec=SecurityControl)
691
+ cached_controls = [mock_control1, mock_control2]
692
+
693
+ # Pre-populate cache
694
+ matcher._catalog_cache[1] = cached_controls
695
+
696
+ result = matcher._get_catalog_controls(1)
697
+
698
+ assert result == cached_controls
699
+ mock_get_list.assert_not_called()
700
+
701
+ @patch("regscale.models.regscale_models.security_control.SecurityControl.get_list_by_catalog")
702
+ @patch("regscale.integrations.control_matcher.Api")
703
+ @patch("regscale.integrations.control_matcher.Application")
704
+ def test_get_catalog_controls_error(self, mock_app_class, mock_api_class, mock_get_list):
705
+ """Test getting catalog controls handles exception"""
706
+ matcher = ControlMatcher()
707
+ mock_get_list.side_effect = Exception("API Error")
708
+
709
+ with patch("regscale.integrations.control_matcher.logger") as mock_logger:
710
+ result = matcher._get_catalog_controls(1)
711
+
712
+ assert result == []
713
+ mock_logger.error.assert_called_once()
714
+ assert 1 not in matcher._catalog_cache
715
+
716
+
717
+ class TestControlMatcherGetControlImplementations:
718
+ """Test cases for _get_control_implementations method"""
719
+
720
+ @patch("regscale.models.regscale_models.control_implementation.ControlImplementation.get_object")
721
+ @patch(
722
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
723
+ )
724
+ @patch("regscale.integrations.control_matcher.Api")
725
+ @patch("regscale.integrations.control_matcher.Application")
726
+ def test_get_control_implementations_first_call(
727
+ self, mock_app_class, mock_api_class, mock_get_label_map, mock_get_object
728
+ ):
729
+ """Test getting control implementations on first call (not cached)"""
730
+ matcher = ControlMatcher()
731
+
732
+ mock_impl1 = MagicMock(spec=ControlImplementation)
733
+ mock_impl1.id = 200
734
+
735
+ mock_impl2 = MagicMock(spec=ControlImplementation)
736
+ mock_impl2.id = 201
737
+
738
+ mock_get_label_map.return_value = {
739
+ "AC-1": 200,
740
+ "AC-2": 201,
741
+ }
742
+
743
+ def get_object_side_effect(impl_id):
744
+ if impl_id == 200:
745
+ return mock_impl1
746
+ elif impl_id == 201:
747
+ return mock_impl2
748
+ return None
749
+
750
+ mock_get_object.side_effect = get_object_side_effect
751
+
752
+ result = matcher._get_control_implementations(50, "securityplans")
753
+
754
+ assert len(result) == 2
755
+ assert result["AC-1"] == mock_impl1
756
+ assert result["AC-2"] == mock_impl2
757
+ mock_get_label_map.assert_called_once_with(50, "securityplans")
758
+ assert (50, "securityplans") in matcher._control_impl_cache
759
+
760
+ @patch(
761
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
762
+ )
763
+ @patch("regscale.integrations.control_matcher.Api")
764
+ @patch("regscale.integrations.control_matcher.Application")
765
+ def test_get_control_implementations_cached(self, mock_app_class, mock_api_class, mock_get_label_map):
766
+ """Test getting control implementations from cache"""
767
+ matcher = ControlMatcher()
768
+
769
+ mock_impl = MagicMock(spec=ControlImplementation)
770
+ cached_impls = {"AC-1": mock_impl}
771
+
772
+ # Pre-populate cache
773
+ matcher._control_impl_cache[(50, "securityplans")] = cached_impls
774
+
775
+ result = matcher._get_control_implementations(50, "securityplans")
776
+
777
+ assert result == cached_impls
778
+ mock_get_label_map.assert_not_called()
779
+
780
+ @patch("regscale.models.regscale_models.control_implementation.ControlImplementation.get_object")
781
+ @patch(
782
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
783
+ )
784
+ @patch("regscale.integrations.control_matcher.Api")
785
+ @patch("regscale.integrations.control_matcher.Application")
786
+ def test_get_control_implementations_with_none(
787
+ self, mock_app_class, mock_api_class, mock_get_label_map, mock_get_object
788
+ ):
789
+ """Test getting control implementations when some objects return None"""
790
+ matcher = ControlMatcher()
791
+
792
+ mock_impl1 = MagicMock(spec=ControlImplementation)
793
+ mock_impl1.id = 200
794
+
795
+ mock_get_label_map.return_value = {
796
+ "AC-1": 200,
797
+ "AC-2": 201, # This will return None
798
+ }
799
+
800
+ def get_object_side_effect(impl_id):
801
+ if impl_id == 200:
802
+ return mock_impl1
803
+ return None
804
+
805
+ mock_get_object.side_effect = get_object_side_effect
806
+
807
+ result = matcher._get_control_implementations(50, "securityplans")
808
+
809
+ # Should only include the valid implementation
810
+ assert len(result) == 1
811
+ assert result["AC-1"] == mock_impl1
812
+ assert "AC-2" not in result
813
+
814
+ @patch(
815
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
816
+ )
817
+ @patch("regscale.integrations.control_matcher.Api")
818
+ @patch("regscale.integrations.control_matcher.Application")
819
+ def test_get_control_implementations_error(self, mock_app_class, mock_api_class, mock_get_label_map):
820
+ """Test getting control implementations handles exception"""
821
+ matcher = ControlMatcher()
822
+ mock_get_label_map.side_effect = Exception("API Error")
823
+
824
+ with patch("regscale.integrations.control_matcher.logger") as mock_logger:
825
+ result = matcher._get_control_implementations(50, "securityplans")
826
+
827
+ assert result == {}
828
+ mock_logger.error.assert_called_once()
829
+ assert (50, "securityplans") not in matcher._control_impl_cache
830
+
831
+
832
+ class TestControlMatcherClearCache:
833
+ """Test cases for clear_cache method"""
834
+
835
+ @patch("regscale.integrations.control_matcher.Api")
836
+ @patch("regscale.integrations.control_matcher.Application")
837
+ def test_clear_cache_empty(self, mock_app_class, mock_api_class):
838
+ """Test clearing cache when already empty"""
839
+ matcher = ControlMatcher()
840
+
841
+ with patch("regscale.integrations.control_matcher.logger") as mock_logger:
842
+ matcher.clear_cache()
843
+
844
+ assert matcher._catalog_cache == {}
845
+ assert matcher._control_impl_cache == {}
846
+ mock_logger.info.assert_called_once_with("Cleared control matcher cache")
847
+
848
+ @patch("regscale.integrations.control_matcher.Api")
849
+ @patch("regscale.integrations.control_matcher.Application")
850
+ def test_clear_cache_with_data(self, mock_app_class, mock_api_class):
851
+ """Test clearing cache with data"""
852
+ matcher = ControlMatcher()
853
+
854
+ # Add data to caches
855
+ matcher._catalog_cache[1] = [MagicMock(spec=SecurityControl)]
856
+ matcher._control_impl_cache[(50, "securityplans")] = {"AC-1": MagicMock(spec=ControlImplementation)}
857
+
858
+ with patch("regscale.integrations.control_matcher.logger") as mock_logger:
859
+ matcher.clear_cache()
860
+
861
+ assert matcher._catalog_cache == {}
862
+ assert matcher._control_impl_cache == {}
863
+ mock_logger.info.assert_called_once_with("Cleared control matcher cache")
864
+
865
+
866
+ class TestControlMatcherEdgeCases:
867
+ """Test cases for edge cases and error scenarios"""
868
+
869
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
870
+ @patch("regscale.integrations.control_matcher.Api")
871
+ @patch("regscale.integrations.control_matcher.Application")
872
+ def test_find_control_with_special_characters(self, mock_app_class, mock_api_class, mock_get_controls):
873
+ """Test finding control with special characters in ID"""
874
+ matcher = ControlMatcher()
875
+
876
+ mock_control = MagicMock(spec=SecurityControl)
877
+ mock_control.controlId = "AC-1(1)"
878
+ mock_get_controls.return_value = [mock_control]
879
+
880
+ # Test with different variations
881
+ result1 = matcher.find_control_in_catalog("AC-1(1)", 1)
882
+ result2 = matcher.find_control_in_catalog("AC-1.1", 1)
883
+
884
+ assert result1 == mock_control
885
+ assert result2 == mock_control
886
+
887
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
888
+ @patch("regscale.integrations.control_matcher.Api")
889
+ @patch("regscale.integrations.control_matcher.Application")
890
+ def test_match_controls_with_duplicates(self, mock_app_class, mock_api_class, mock_find_impl):
891
+ """Test matching controls when list contains duplicates"""
892
+ matcher = ControlMatcher()
893
+
894
+ mock_impl1 = MagicMock(spec=ControlImplementation)
895
+ mock_impl2 = MagicMock(spec=ControlImplementation)
896
+
897
+ def find_impl_side_effect(control_id, parent_id, parent_module="securityplans", catalog_id=None):
898
+ if control_id == "AC-1":
899
+ return mock_impl1
900
+ elif control_id == "AC-2":
901
+ return mock_impl2
902
+ return None
903
+
904
+ mock_find_impl.side_effect = find_impl_side_effect
905
+
906
+ control_ids = ["AC-1", "AC-1", "AC-2"]
907
+ result = matcher.match_controls_to_implementations(control_ids, 50)
908
+
909
+ # Result is a dict, so duplicates are collapsed - should have 2 unique keys
910
+ assert len(result) == 2
911
+ assert result["AC-1"] == mock_impl1
912
+ assert result["AC-2"] == mock_impl2
913
+ # Should still call find_impl for each entry in the list, including duplicates
914
+ assert mock_find_impl.call_count == 3
915
+
916
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
917
+ @patch("regscale.integrations.control_matcher.Api")
918
+ @patch("regscale.integrations.control_matcher.Application")
919
+ def test_find_by_pattern_with_empty_string(self, mock_app_class, mock_api_class, mock_get_controls):
920
+ """Test finding controls with empty string pattern"""
921
+ matcher = ControlMatcher()
922
+
923
+ mock_control = MagicMock(spec=SecurityControl)
924
+ mock_control.controlId = "AC-1"
925
+ mock_control.title = "Access Control"
926
+ mock_get_controls.return_value = [mock_control]
927
+
928
+ result = matcher.find_controls_by_pattern("", 1)
929
+
930
+ # Empty pattern should match everything
931
+ assert len(result) == 1
932
+ assert mock_control in result
933
+
934
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
935
+ @patch("regscale.integrations.control_matcher.Api")
936
+ @patch("regscale.integrations.control_matcher.Application")
937
+ def test_find_implementation_different_parent_modules(self, mock_app_class, mock_api_class, mock_get_impls):
938
+ """Test finding implementations with different parent modules"""
939
+ matcher = ControlMatcher()
940
+
941
+ mock_impl1 = MagicMock(spec=ControlImplementation)
942
+ mock_impl2 = MagicMock(spec=ControlImplementation)
943
+
944
+ def get_impls_side_effect(parent_id, parent_module):
945
+ if parent_module == "securityplans":
946
+ return {"AC-1": mock_impl1}
947
+ elif parent_module == "assessments":
948
+ return {"AC-1": mock_impl2}
949
+ return {}
950
+
951
+ mock_get_impls.side_effect = get_impls_side_effect
952
+
953
+ result1 = matcher.find_control_implementation("AC-1", 50, "securityplans")
954
+ result2 = matcher.find_control_implementation("AC-1", 51, "assessments")
955
+
956
+ assert result1 == mock_impl1
957
+ assert result2 == mock_impl2
958
+
959
+
960
+ class TestControlMatcherLeadingZeros:
961
+ """Test cases for control IDs with leading zeros"""
962
+
963
+ @patch("regscale.integrations.control_matcher.Api")
964
+ @patch("regscale.integrations.control_matcher.Application")
965
+ def test_normalize_control_id_with_leading_zeros(self, mock_app_class, mock_api_class):
966
+ """Test normalizing control IDs with leading zeros"""
967
+ matcher = ControlMatcher()
968
+
969
+ test_cases = [
970
+ ("AC-01", "AC-1"),
971
+ ("AC-17", "AC-17"),
972
+ ("AC-01.02", "AC-1.2"),
973
+ ("AC-17.02", "AC-17.2"),
974
+ ("AC-1.1", "AC-1.1"),
975
+ ]
976
+
977
+ for input_id, expected in test_cases:
978
+ result = matcher._normalize_control_id(input_id)
979
+ assert result == expected, f"Failed for input {input_id}"
980
+
981
+ @patch("regscale.integrations.control_matcher.Api")
982
+ @patch("regscale.integrations.control_matcher.Application")
983
+ def test_get_control_id_variations_simple(self, mock_app_class, mock_api_class):
984
+ """Test generating variations for simple control IDs"""
985
+ matcher = ControlMatcher()
986
+
987
+ result = matcher._get_control_id_variations("AC-1")
988
+ expected = {"AC-1", "AC-01"}
989
+ assert result == expected
990
+
991
+ @patch("regscale.integrations.control_matcher.Api")
992
+ @patch("regscale.integrations.control_matcher.Application")
993
+ def test_get_control_id_variations_with_enhancement(self, mock_app_class, mock_api_class):
994
+ """Test generating variations for control IDs with enhancements"""
995
+ matcher = ControlMatcher()
996
+
997
+ result = matcher._get_control_id_variations("AC-17.2")
998
+ expected = {
999
+ "AC-17.2",
1000
+ "AC-17.02",
1001
+ "AC-17(2)",
1002
+ "AC-17(02)",
1003
+ }
1004
+ assert result == expected
1005
+
1006
+ @patch("regscale.integrations.control_matcher.Api")
1007
+ @patch("regscale.integrations.control_matcher.Application")
1008
+ def test_get_control_id_variations_with_leading_zeros_input(self, mock_app_class, mock_api_class):
1009
+ """Test generating variations when input has leading zeros"""
1010
+ matcher = ControlMatcher()
1011
+
1012
+ result = matcher._get_control_id_variations("AC-01")
1013
+ expected = {"AC-1", "AC-01"}
1014
+ assert result == expected
1015
+
1016
+ @patch("regscale.integrations.control_matcher.Api")
1017
+ @patch("regscale.integrations.control_matcher.Application")
1018
+ def test_get_control_id_variations_with_parentheses_input(self, mock_app_class, mock_api_class):
1019
+ """Test generating variations when input has parentheses"""
1020
+ matcher = ControlMatcher()
1021
+
1022
+ result = matcher._get_control_id_variations("AC-17(02)")
1023
+ expected = {
1024
+ "AC-17.2",
1025
+ "AC-17.02",
1026
+ "AC-17(2)",
1027
+ "AC-17(02)",
1028
+ }
1029
+ assert result == expected
1030
+
1031
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
1032
+ @patch("regscale.integrations.control_matcher.Api")
1033
+ @patch("regscale.integrations.control_matcher.Application")
1034
+ def test_find_control_in_catalog_with_leading_zeros(self, mock_app_class, mock_api_class, mock_get_controls):
1035
+ """Test finding controls with leading zeros in catalog"""
1036
+ matcher = ControlMatcher()
1037
+
1038
+ # Catalog has control with leading zeros
1039
+ mock_control = MagicMock(spec=SecurityControl)
1040
+ mock_control.controlId = "AC-01"
1041
+ mock_control.id = 100
1042
+
1043
+ mock_get_controls.return_value = [mock_control]
1044
+
1045
+ # Search without leading zero should find it
1046
+ result = matcher.find_control_in_catalog("AC-1", 1)
1047
+ assert result == mock_control
1048
+
1049
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
1050
+ @patch("regscale.integrations.control_matcher.Api")
1051
+ @patch("regscale.integrations.control_matcher.Application")
1052
+ def test_find_control_in_catalog_search_with_leading_zeros(self, mock_app_class, mock_api_class, mock_get_controls):
1053
+ """Test finding controls when search ID has leading zeros"""
1054
+ matcher = ControlMatcher()
1055
+
1056
+ # Catalog has control without leading zeros
1057
+ mock_control = MagicMock(spec=SecurityControl)
1058
+ mock_control.controlId = "AC-1"
1059
+ mock_control.id = 100
1060
+
1061
+ mock_get_controls.return_value = [mock_control]
1062
+
1063
+ # Search with leading zero should find it
1064
+ result = matcher.find_control_in_catalog("AC-01", 1)
1065
+ assert result == mock_control
1066
+
1067
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_catalog_controls")
1068
+ @patch("regscale.integrations.control_matcher.Api")
1069
+ @patch("regscale.integrations.control_matcher.Application")
1070
+ def test_find_control_with_leading_zeros_enhancement(self, mock_app_class, mock_api_class, mock_get_controls):
1071
+ """Test finding controls with leading zeros in enhancement numbers"""
1072
+ matcher = ControlMatcher()
1073
+
1074
+ # Catalog has control with leading zeros in enhancement
1075
+ mock_control = MagicMock(spec=SecurityControl)
1076
+ mock_control.controlId = "AC-17(02)"
1077
+ mock_control.id = 100
1078
+
1079
+ mock_get_controls.return_value = [mock_control]
1080
+
1081
+ # Search with different formats should all find it
1082
+ result1 = matcher.find_control_in_catalog("AC-17.2", 1)
1083
+ result2 = matcher.find_control_in_catalog("AC-17(2)", 1)
1084
+ result3 = matcher.find_control_in_catalog("AC-17.02", 1)
1085
+
1086
+ assert result1 == mock_control
1087
+ assert result2 == mock_control
1088
+ assert result3 == mock_control
1089
+
1090
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
1091
+ @patch("regscale.integrations.control_matcher.Api")
1092
+ @patch("regscale.integrations.control_matcher.Application")
1093
+ def test_find_implementation_with_leading_zeros(self, mock_app_class, mock_api_class, mock_get_impls):
1094
+ """Test finding implementation when control IDs have leading zeros"""
1095
+ matcher = ControlMatcher()
1096
+
1097
+ mock_impl = MagicMock(spec=ControlImplementation)
1098
+ mock_impl.id = 200
1099
+ mock_impl.controlID = 100
1100
+
1101
+ # Implementation key has leading zero
1102
+ mock_get_impls.return_value = {
1103
+ "AC-01": mock_impl,
1104
+ }
1105
+
1106
+ # Search without leading zero should find it
1107
+ result = matcher.find_control_implementation("AC-1", 50)
1108
+ assert result == mock_impl
1109
+
1110
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
1111
+ @patch("regscale.integrations.control_matcher.Api")
1112
+ @patch("regscale.integrations.control_matcher.Application")
1113
+ def test_find_implementation_search_with_leading_zeros(self, mock_app_class, mock_api_class, mock_get_impls):
1114
+ """Test finding implementation when search ID has leading zeros"""
1115
+ matcher = ControlMatcher()
1116
+
1117
+ mock_impl = MagicMock(spec=ControlImplementation)
1118
+ mock_impl.id = 200
1119
+ mock_impl.controlID = 100
1120
+
1121
+ # Implementation key has no leading zero
1122
+ mock_get_impls.return_value = {
1123
+ "AC-1": mock_impl,
1124
+ }
1125
+
1126
+ # Search with leading zero should find it
1127
+ result = matcher.find_control_implementation("AC-01", 50)
1128
+ assert result == mock_impl
1129
+
1130
+ @patch("regscale.integrations.control_matcher.ControlMatcher._get_control_implementations")
1131
+ @patch("regscale.integrations.control_matcher.Api")
1132
+ @patch("regscale.integrations.control_matcher.Application")
1133
+ def test_find_implementation_with_leading_zeros_complex(self, mock_app_class, mock_api_class, mock_get_impls):
1134
+ """Test finding implementation with complex leading zero scenarios"""
1135
+ matcher = ControlMatcher()
1136
+
1137
+ mock_impl = MagicMock(spec=ControlImplementation)
1138
+ mock_impl.id = 200
1139
+
1140
+ # Implementation key has leading zeros in enhancement
1141
+ mock_get_impls.return_value = {
1142
+ "AC-17(02)": mock_impl,
1143
+ }
1144
+
1145
+ # Search with different formats should find it
1146
+ result1 = matcher.find_control_implementation("AC-17.2", 50)
1147
+ result2 = matcher.find_control_implementation("AC-17(2)", 50)
1148
+ result3 = matcher.find_control_implementation("AC-17.02", 50)
1149
+
1150
+ assert result1 == mock_impl
1151
+ assert result2 == mock_impl
1152
+ assert result3 == mock_impl
1153
+
1154
+
1155
+ class TestControlMatcherIntegrationScenarios:
1156
+ """Integration test scenarios for complex workflows"""
1157
+
1158
+ @patch("regscale.models.regscale_models.control_implementation.ControlImplementation.get_object")
1159
+ @patch(
1160
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
1161
+ )
1162
+ @patch("regscale.models.regscale_models.security_control.SecurityControl.get_list_by_catalog")
1163
+ @patch("regscale.integrations.control_matcher.Api")
1164
+ @patch("regscale.integrations.control_matcher.Application")
1165
+ def test_full_workflow_with_caching(
1166
+ self, mock_app_class, mock_api_class, mock_get_catalog, mock_get_label_map, mock_get_object
1167
+ ):
1168
+ """Test full workflow with multiple operations and caching"""
1169
+ matcher = ControlMatcher()
1170
+
1171
+ # Setup catalog controls
1172
+ mock_control1 = MagicMock(spec=SecurityControl)
1173
+ mock_control1.controlId = "AC-1"
1174
+ mock_control1.id = 100
1175
+ mock_control1.title = "Access Control Policy"
1176
+
1177
+ mock_control2 = MagicMock(spec=SecurityControl)
1178
+ mock_control2.controlId = "AC-2"
1179
+ mock_control2.id = 101
1180
+ mock_control2.title = "Account Management"
1181
+
1182
+ mock_get_catalog.return_value = [mock_control1, mock_control2]
1183
+
1184
+ # Setup implementations
1185
+ mock_impl1 = MagicMock(spec=ControlImplementation)
1186
+ mock_impl1.id = 200
1187
+ mock_impl1.controlID = 100
1188
+
1189
+ mock_get_label_map.return_value = {"AC-1": 200}
1190
+ mock_get_object.return_value = mock_impl1
1191
+
1192
+ # First operation: find control in catalog
1193
+ control = matcher.find_control_in_catalog("AC-1", 1)
1194
+ assert control == mock_control1
1195
+ assert mock_get_catalog.call_count == 1
1196
+
1197
+ # Second operation: find same control (should use cache)
1198
+ control2 = matcher.find_control_in_catalog("AC-2", 1)
1199
+ assert control2 == mock_control2
1200
+ assert mock_get_catalog.call_count == 1 # Should not increase
1201
+
1202
+ # Third operation: find implementation
1203
+ impl = matcher.find_control_implementation("AC-1", 50)
1204
+ assert impl == mock_impl1
1205
+ assert mock_get_label_map.call_count == 1
1206
+
1207
+ # Fourth operation: find same implementation (should use cache)
1208
+ impl2 = matcher.find_control_implementation("AC-1", 50)
1209
+ assert impl2 == mock_impl1
1210
+ assert mock_get_label_map.call_count == 1 # Should not increase
1211
+
1212
+ # Clear cache
1213
+ matcher.clear_cache()
1214
+
1215
+ # Fifth operation: after cache clear, should fetch again
1216
+ control3 = matcher.find_control_in_catalog("AC-1", 1)
1217
+ assert control3 == mock_control1
1218
+ assert mock_get_catalog.call_count == 2 # Should increase now
1219
+
1220
+ @patch("regscale.integrations.control_matcher.ControlMatcher.find_control_implementation")
1221
+ @patch("regscale.integrations.control_matcher.Api")
1222
+ @patch("regscale.integrations.control_matcher.Application")
1223
+ def test_bulk_operations_with_mixed_results(self, mock_app_class, mock_api_class, mock_find_impl):
1224
+ """Test bulk operations with some successes and some failures"""
1225
+ matcher = ControlMatcher()
1226
+
1227
+ mock_impl1 = MagicMock(spec=ControlImplementation)
1228
+ mock_impl2 = MagicMock(spec=ControlImplementation)
1229
+
1230
+ def find_impl_side_effect(control_id, parent_id, parent_module="securityplans", catalog_id=None):
1231
+ impl_map = {
1232
+ "AC-1": mock_impl1,
1233
+ "AC-2": mock_impl2,
1234
+ }
1235
+ return impl_map.get(control_id)
1236
+
1237
+ mock_find_impl.side_effect = find_impl_side_effect
1238
+
1239
+ # Bulk match with mixed results
1240
+ mappings = {
1241
+ "finding-001": "AC-1", # Will find
1242
+ "finding-002": "AC-2", # Will find
1243
+ "finding-003": "SI-4", # Won't find
1244
+ "finding-004": "CM-6", # Won't find
1245
+ }
1246
+
1247
+ result = matcher.bulk_match_controls(mappings, 50)
1248
+
1249
+ assert result["finding-001"] == mock_impl1
1250
+ assert result["finding-002"] == mock_impl2
1251
+ assert result["finding-003"] is None
1252
+ assert result["finding-004"] is None
1253
+ assert len(result) == 4
1254
+
1255
+ @patch("regscale.models.regscale_models.control_implementation.ControlImplementation.get_object")
1256
+ @patch(
1257
+ "regscale.models.regscale_models.control_implementation.ControlImplementation.get_control_label_map_by_parent"
1258
+ )
1259
+ @patch("regscale.models.regscale_models.security_control.SecurityControl.get_list_by_catalog")
1260
+ @patch("regscale.integrations.control_matcher.Api")
1261
+ @patch("regscale.integrations.control_matcher.Application")
1262
+ def test_workflow_with_leading_zeros_catalog(
1263
+ self, mock_app_class, mock_api_class, mock_get_catalog, mock_get_label_map, mock_get_object
1264
+ ):
1265
+ """Test workflow when catalog has control IDs with leading zeros"""
1266
+ matcher = ControlMatcher()
1267
+
1268
+ # Catalog has controls with leading zeros
1269
+ mock_control1 = MagicMock(spec=SecurityControl)
1270
+ mock_control1.controlId = "AC-01"
1271
+ mock_control1.id = 100
1272
+
1273
+ mock_control2 = MagicMock(spec=SecurityControl)
1274
+ mock_control2.controlId = "AC-17(02)"
1275
+ mock_control2.id = 101
1276
+
1277
+ mock_get_catalog.return_value = [mock_control1, mock_control2]
1278
+
1279
+ # Implementations have standard format
1280
+ mock_impl1 = MagicMock(spec=ControlImplementation)
1281
+ mock_impl1.id = 200
1282
+ mock_impl1.controlID = 100
1283
+
1284
+ mock_impl2 = MagicMock(spec=ControlImplementation)
1285
+ mock_impl2.id = 201
1286
+ mock_impl2.controlID = 101
1287
+
1288
+ mock_get_label_map.return_value = {
1289
+ "AC-1": 200,
1290
+ "AC-17.2": 201,
1291
+ }
1292
+
1293
+ def get_object_side_effect(impl_id):
1294
+ if impl_id == 200:
1295
+ return mock_impl1
1296
+ elif impl_id == 201:
1297
+ return mock_impl2
1298
+ return None
1299
+
1300
+ mock_get_object.side_effect = get_object_side_effect
1301
+
1302
+ # Search with standard format should find controls with leading zeros
1303
+ control1 = matcher.find_control_in_catalog("AC-1", 1)
1304
+ assert control1 == mock_control1
1305
+
1306
+ control2 = matcher.find_control_in_catalog("AC-17.2", 1)
1307
+ assert control2 == mock_control2
1308
+
1309
+ # Find implementations should work with either format
1310
+ impl1 = matcher.find_control_implementation("AC-01", 50)
1311
+ assert impl1 == mock_impl1
1312
+
1313
+ impl2 = matcher.find_control_implementation("AC-17(02)", 50)
1314
+ assert impl2 == mock_impl2