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