regscale-cli 6.25.0.1__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 (84) 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/synqly/assets.py +17 -0
  11. regscale/integrations/commercial/wizv2/click.py +26 -26
  12. regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
  13. regscale/integrations/commercial/wizv2/constants.py +20 -71
  14. regscale/integrations/commercial/wizv2/scanner.py +3 -3
  15. regscale/integrations/compliance_integration.py +67 -2
  16. regscale/integrations/control_matcher.py +358 -0
  17. regscale/integrations/due_date_handler.py +118 -6
  18. regscale/integrations/milestone_manager.py +291 -0
  19. regscale/integrations/public/__init__.py +1 -0
  20. regscale/integrations/public/cci_importer.py +37 -38
  21. regscale/integrations/public/fedramp/click.py +60 -2
  22. regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
  23. regscale/integrations/scanner_integration.py +199 -130
  24. regscale/models/integration_models/cisa_kev_data.json +199 -4
  25. regscale/models/integration_models/nexpose.py +36 -10
  26. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  27. regscale/models/locking.py +12 -8
  28. regscale/models/platform.py +1 -2
  29. regscale/models/regscale_models/control_implementation.py +46 -21
  30. regscale/models/regscale_models/issue.py +256 -94
  31. regscale/models/regscale_models/milestone.py +1 -1
  32. regscale/models/regscale_models/regscale_model.py +6 -1
  33. regscale/templates/__init__.py +0 -0
  34. regscale/utils/threading/threadhandler.py +20 -15
  35. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
  36. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +84 -37
  37. tests/regscale/integrations/commercial/__init__.py +0 -0
  38. tests/regscale/integrations/commercial/conftest.py +28 -0
  39. tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
  40. tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
  41. tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
  42. tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
  43. tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
  44. tests/regscale/integrations/commercial/test_aws.py +3731 -0
  45. tests/regscale/integrations/commercial/test_burp.py +48 -0
  46. tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
  47. tests/regscale/integrations/commercial/test_dependabot.py +341 -0
  48. tests/regscale/integrations/commercial/test_gcp.py +1543 -0
  49. tests/regscale/integrations/commercial/test_gitlab.py +549 -0
  50. tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
  51. tests/regscale/integrations/commercial/test_jira.py +1814 -0
  52. tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
  53. tests/regscale/integrations/commercial/test_okta.py +1228 -0
  54. tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
  55. tests/regscale/integrations/commercial/test_sicura.py +350 -0
  56. tests/regscale/integrations/commercial/test_snow.py +423 -0
  57. tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
  58. tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
  59. tests/regscale/integrations/commercial/test_stig.py +33 -0
  60. tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
  61. tests/regscale/integrations/commercial/test_stigv2.py +406 -0
  62. tests/regscale/integrations/commercial/test_wiz.py +1469 -0
  63. tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
  64. tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
  65. tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
  66. tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
  67. tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
  68. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
  69. tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
  70. tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
  71. tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
  72. tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
  73. tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
  74. tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
  75. tests/regscale/integrations/public/fedramp/__init__.py +1 -0
  76. tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
  77. tests/regscale/integrations/test_control_matcher.py +1314 -0
  78. tests/regscale/integrations/test_control_matching.py +155 -0
  79. tests/regscale/integrations/test_milestone_manager.py +408 -0
  80. tests/regscale/models/test_issue.py +378 -1
  81. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
  82. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
  83. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
  84. {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1814 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Test for jira integration in RegScale CLI"""
4
+ # standard python imports
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from unittest.mock import MagicMock, patch
10
+ from datetime import datetime, timedelta
11
+
12
+ import pytest
13
+ from rich.progress import Progress
14
+ from jira import JIRAError
15
+
16
+ from regscale.core.app.application import Application
17
+ from regscale.core.app.api import Api
18
+ from regscale.core.app.utils.app_utils import compute_hash, create_progress_object, get_current_datetime
19
+ from regscale.integrations.commercial.jira import (
20
+ _generate_jira_comment,
21
+ check_and_close_tasks,
22
+ create_and_update_regscale_issues,
23
+ create_issue_in_jira,
24
+ create_jira_client,
25
+ download_regscale_attachments_to_directory,
26
+ fetch_jira_objects,
27
+ get_regscale_data_and_attachments,
28
+ map_jira_due_date,
29
+ map_jira_to_regscale_issue,
30
+ sync_regscale_and_jira,
31
+ sync_regscale_objects_to_jira,
32
+ sync_regscale_to_jira,
33
+ task_and_attachments_sync,
34
+ update_regscale_issues,
35
+ create_regscale_task_from_jira,
36
+ create_and_update_regscale_tasks,
37
+ process_tasks_for_sync,
38
+ upload_files_to_jira,
39
+ upload_files_to_regscale,
40
+ validate_issue_type,
41
+ )
42
+ from regscale.models import File, Issue, SecurityPlan
43
+ from regscale.models.regscale_models.task import Task
44
+ from tests import CLITestFixture
45
+
46
+
47
+ class TestJira(CLITestFixture):
48
+ JIRA_PROJECT = "SNES"
49
+ PATH = "regscale.integrations.commercial.jira"
50
+ security_plan = None
51
+
52
+ @pytest.fixture(autouse=True)
53
+ def setup_ssp(self, create_security_plan):
54
+ self.security_plan = create_security_plan
55
+
56
+ @property
57
+ def PARENT_ID(self):
58
+ """Get the parent ID from the existing SSP"""
59
+ return self.security_plan.id
60
+
61
+ @property
62
+ def PARENT_MODULE(self):
63
+ """Get the parent module from the existing SSP"""
64
+ return self.security_plan.get_module_string()
65
+
66
+ @pytest.fixture
67
+ def jira_client(self):
68
+ """Setup jira client"""
69
+ return create_jira_client(
70
+ config=self.config,
71
+ )
72
+
73
+ @staticmethod
74
+ @pytest.fixture(params=[True, False])
75
+ def fetch_attachments(request):
76
+ """Pytest fixture that will run twice:
77
+ first time with a true value, second time with a false value"""
78
+ return request.param
79
+
80
+ @pytest.fixture
81
+ def jira_issues(self, jira_client, fetch_attachments):
82
+ """Fixture for fetching Jira issues and attachments"""
83
+ return fetch_jira_objects(jira_client=jira_client, jira_project=self.JIRA_PROJECT, jira_issue_type="Bug")
84
+
85
+ @pytest.fixture
86
+ def jira_tasks(self, jira_client, fetch_attachments):
87
+ """Fixture for fetching Jira tasks and attachments"""
88
+ return fetch_jira_objects(
89
+ jira_client=jira_client,
90
+ jira_project=self.JIRA_PROJECT,
91
+ jira_issue_type="Task",
92
+ sync_tasks_only=True,
93
+ )
94
+
95
+ @pytest.fixture
96
+ def get_jira_issue(self, jira_client):
97
+ """Fixture for creating a test Issue in Jira"""
98
+ jira_issue = jira_client.create_issue(
99
+ fields={
100
+ "project": {"key": self.JIRA_PROJECT},
101
+ "summary": f"{self.title_prefix} Jira Integration Test",
102
+ "description": "Test issue for integration testing",
103
+ "issuetype": {"name": "Bug"},
104
+ }
105
+ )
106
+ yield jira_issue
107
+ jira_issue.delete() # cleanup afterwards
108
+
109
+ @pytest.fixture
110
+ def get_jira_task(self, jira_client):
111
+ """Fixture for creating a test Task in Jira"""
112
+ jira_task = jira_client.create_issue(
113
+ fields={
114
+ "project": {"key": self.JIRA_PROJECT},
115
+ "summary": f"{self.title_prefix} Jira Integration Test",
116
+ "description": "Test task for integration testing",
117
+ "issuetype": {"name": "Task"},
118
+ }
119
+ )
120
+ yield jira_task
121
+ jira_task.delete() # cleanup afterwards
122
+
123
+ @pytest.fixture
124
+ def get_jira_issue_with_attachment(self, get_jira_issue, jira_client):
125
+ """Fixture for creating a test Issue in Jira with an attachment"""
126
+ issue = get_jira_issue
127
+ file_path = os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "attachment.txt")
128
+ with open(file_path, "rb") as f:
129
+ jira_client.add_attachment(issue=issue, filename="test_attachment.txt", attachment=f)
130
+ yield jira_client.issue(issue.id)
131
+
132
+ @pytest.fixture
133
+ def get_jira_task_with_attachment(self, get_jira_task, jira_client):
134
+ """Fixture for creating a test Task in Jira with an attachment"""
135
+ task = get_jira_task
136
+ file_path = os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "attachment.txt")
137
+ with open(file_path, "rb") as f:
138
+ jira_client.add_attachment(issue=task, filename="test_attachment.txt", attachment=f)
139
+ yield jira_client.issue(task.id)
140
+
141
+ @pytest.fixture
142
+ def regscale_issues_and_attachments(self, fetch_attachments, regscale_issue_and_attachment):
143
+ """Fixture for fetching RegScale issues and attachments"""
144
+ _ = regscale_issue_and_attachment
145
+
146
+ if fetch_attachments:
147
+ return Issue.get_objects_and_attachments_by_parent(
148
+ parent_id=self.PARENT_ID,
149
+ parent_module=self.PARENT_MODULE,
150
+ )
151
+ else:
152
+ return (
153
+ Issue.get_all_by_parent(
154
+ parent_id=self.PARENT_ID,
155
+ parent_module=self.PARENT_MODULE,
156
+ ),
157
+ [],
158
+ )
159
+
160
+ @pytest.fixture
161
+ def regscale_tasks_and_attachments(self, fetch_attachments, regscale_task_and_attachment):
162
+ """Fixture for fetching RegScale tasks and attachments"""
163
+ _ = regscale_task_and_attachment
164
+ if fetch_attachments:
165
+ return Task.get_objects_and_attachments_by_parent(
166
+ parent_id=self.PARENT_ID,
167
+ parent_module=self.PARENT_MODULE,
168
+ )
169
+ else:
170
+ return (
171
+ Task.get_all_by_parent(
172
+ parent_id=self.PARENT_ID,
173
+ parent_module=self.PARENT_MODULE,
174
+ ),
175
+ [],
176
+ )
177
+
178
+ @pytest.fixture
179
+ def regscale_issue_and_attachment(self, fetch_attachments):
180
+ """Fixture for creating RegScale issue and attachment"""
181
+ issue = Issue(
182
+ title=f"{self.title_prefix} Jira Issue Integration Test",
183
+ description="Security plan for Jira integration testing",
184
+ parentId=self.PARENT_ID,
185
+ parentModule=self.PARENT_MODULE,
186
+ dueDate=get_current_datetime(),
187
+ status="Open",
188
+ ).create()
189
+ if fetch_attachments:
190
+ File.upload_file_to_regscale(
191
+ file_name=os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "attachment.txt"),
192
+ parent_id=issue.id,
193
+ parent_module=issue.get_module_string(),
194
+ api=self.api,
195
+ )
196
+ return issue
197
+
198
+ @pytest.fixture
199
+ def regscale_task_and_attachment(self, fetch_attachments):
200
+ """Fixture for creating RegScale task and attachment"""
201
+ task = Task(
202
+ status="Backlog",
203
+ title=f"{self.title_prefix} Jira Task Integration Test",
204
+ description="Task for Jira integration testing",
205
+ parentId=self.PARENT_ID,
206
+ parentModule=self.PARENT_MODULE,
207
+ dueDate=get_current_datetime(),
208
+ ).create()
209
+ if fetch_attachments:
210
+ File.upload_file_to_regscale(
211
+ file_name=os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "image.png"),
212
+ parent_id=task.id,
213
+ parent_module=task.get_module_string(),
214
+ api=self.api,
215
+ )
216
+ return task
217
+
218
+ @pytest.fixture
219
+ def mock_job_progres_object(self):
220
+ """Mock job_progress object"""
221
+ with patch.object(self.PATH, "job_progress", new=create_progress_object()) as job_progress:
222
+ yield job_progress
223
+
224
+ def test_init(self):
225
+ """Test init file and config"""
226
+ self.verify_config(
227
+ [
228
+ "jiraUrl",
229
+ "jiraApiToken",
230
+ "jiraUserName",
231
+ ]
232
+ )
233
+
234
+ @patch(f"{PATH}.Task.get_objects_and_attachments_by_parent")
235
+ def test_get_regscale_data_and_attachments_sync_both(self, mock_get_objects_and_attachments_by_parent):
236
+ """Test getting RegScale data and attachments"""
237
+ mock_get_objects_and_attachments_by_parent.return_value = (["objects"], {"attachments": ["attachment"]})
238
+ regscale_issues, regscale_attachments = get_regscale_data_and_attachments(
239
+ parent_id=self.PARENT_ID,
240
+ parent_module=self.PARENT_MODULE,
241
+ sync_attachments=True,
242
+ sync_tasks_only=True,
243
+ )
244
+ assert regscale_issues == ["objects"]
245
+ assert regscale_attachments == {"attachments": ["attachment"]}
246
+
247
+ @patch(f"{PATH}.Task.get_all_by_parent")
248
+ def test_get_regscale_data_and_attachments_sync_tasks_only(self, mock_get_all_by_parent):
249
+ """Test getting RegScale data and no attachments"""
250
+ mock_get_all_by_parent.return_value = ["objects"]
251
+ regscale_issues, regscale_attachments = get_regscale_data_and_attachments(
252
+ parent_id=self.PARENT_ID,
253
+ parent_module=self.PARENT_MODULE,
254
+ sync_attachments=False,
255
+ sync_tasks_only=True,
256
+ )
257
+ assert regscale_issues == ["objects"]
258
+ assert regscale_attachments == []
259
+
260
+ @patch(f"{PATH}.Issue.get_objects_and_attachments_by_parent")
261
+ def test_get_regscale_data_and_attachments_sync_attachments_only(self, mock_get_objects_and_attachments_by_parent):
262
+ """Test getting RegScale attachments only"""
263
+ mock_get_objects_and_attachments_by_parent.return_value = (["objects"], {"attachments": ["attachment"]})
264
+ regscale_issues, regscale_attachments = get_regscale_data_and_attachments(
265
+ parent_id=self.PARENT_ID,
266
+ parent_module=self.PARENT_MODULE,
267
+ sync_attachments=True,
268
+ sync_tasks_only=False,
269
+ )
270
+ assert regscale_issues == ["objects"]
271
+ assert regscale_attachments == {"attachments": ["attachment"]}
272
+
273
+ @patch(f"{PATH}.Issue.get_all_by_parent")
274
+ def test_get_regscale_data_and_attachments_sync_issues_only(self, mock_get_all_by_parent):
275
+ """Test getting RegScale issues only"""
276
+ mock_get_all_by_parent.return_value = ["objects"]
277
+ regscale_issues, regscale_attachments = get_regscale_data_and_attachments(
278
+ parent_id=self.PARENT_ID,
279
+ parent_module=self.PARENT_MODULE,
280
+ sync_attachments=False,
281
+ sync_tasks_only=False,
282
+ )
283
+ assert regscale_issues == ["objects"]
284
+ assert regscale_attachments == []
285
+
286
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
287
+ def test_sync_regscale_and_jira(self, mock_check_license, fetch_attachments):
288
+ """Test the entire Jira & RegScale sync process"""
289
+ mock_check_license.return_value.config = self.config
290
+ try:
291
+ sync_regscale_and_jira(
292
+ parent_id=self.PARENT_ID,
293
+ parent_module=self.PARENT_MODULE,
294
+ jira_project=self.JIRA_PROJECT,
295
+ jira_issue_type="Bug",
296
+ sync_attachments=fetch_attachments,
297
+ )
298
+ except Exception as e:
299
+ pytest.fail("Jira & RegScale sync failed: {}".format(e))
300
+
301
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
302
+ def test_sync_regscale_and_jira_tasks(self, mock_check_license, fetch_attachments):
303
+ """Test the entire Jira & RegScale task sync process"""
304
+ mock_check_license.return_value.config = self.config
305
+ try:
306
+ sync_regscale_and_jira(
307
+ parent_id=self.PARENT_ID,
308
+ parent_module=self.PARENT_MODULE,
309
+ jira_project=self.JIRA_PROJECT,
310
+ jira_issue_type="Task",
311
+ sync_attachments=fetch_attachments,
312
+ sync_tasks_only=True,
313
+ )
314
+ except Exception as e:
315
+ pytest.fail("Jira & RegScale task sync failed: {}".format(e))
316
+
317
+ @patch(f"{PATH}.sync_regscale_objects_to_jira")
318
+ @patch(f"{PATH}.sync_regscale_to_jira", return_value=[])
319
+ @patch(f"{PATH}.create_jira_client")
320
+ @patch(f"{PATH}.fetch_jira_objects", return_value=[])
321
+ @patch(f"{PATH}.get_regscale_data_and_attachments", return_value=([], {}))
322
+ @patch(f"{PATH}.Api", return_value=MagicMock(spec=Api))
323
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
324
+ def test_sync_regscale_and_jira_no_updates(
325
+ self,
326
+ mock_check_license,
327
+ mock_api,
328
+ mock_get_regscale_data_and_attachments,
329
+ mock_fetch_jira_objects,
330
+ mock_create_jira_client,
331
+ mock_sync_regscale_to_jira,
332
+ mock_sync_regscale_objects_to_jira,
333
+ fetch_attachments,
334
+ ):
335
+ """Test sync_regscale_and_jira without updates from either side"""
336
+ # mock jira client so we can check it was correctly used later
337
+ mock_jira_client = MagicMock()
338
+ mock_create_jira_client.return_value = mock_jira_client
339
+
340
+ sync_regscale_and_jira(
341
+ parent_id=self.PARENT_ID,
342
+ parent_module=self.PARENT_MODULE,
343
+ jira_project=self.JIRA_PROJECT,
344
+ jira_issue_type="Bug",
345
+ sync_attachments=fetch_attachments,
346
+ )
347
+
348
+ # check that we get the jira client correctly
349
+ mock_create_jira_client.assert_called_once()
350
+
351
+ # check that we correctly fetch objects from jira and regscale
352
+ mock_get_regscale_data_and_attachments.assert_called_once_with(
353
+ parent_id=self.PARENT_ID,
354
+ parent_module=self.PARENT_MODULE,
355
+ sync_attachments=fetch_attachments,
356
+ sync_tasks_only=False,
357
+ )
358
+ mock_fetch_jira_objects.assert_called_once_with(
359
+ jira_client=mock_jira_client,
360
+ jira_project=self.JIRA_PROJECT,
361
+ jql_str="project = SNES",
362
+ jira_issue_type="Bug",
363
+ sync_tasks_only=False,
364
+ )
365
+
366
+ # check that no updates were made because we did not find any objects from jira/regscale
367
+ mock_sync_regscale_to_jira.assert_not_called()
368
+ mock_sync_regscale_objects_to_jira.assert_not_called()
369
+
370
+ @patch(f"{PATH}.sync_regscale_objects_to_jira")
371
+ @patch(f"{PATH}.sync_regscale_to_jira", return_value=[])
372
+ @patch(f"{PATH}.create_jira_client")
373
+ @patch(f"{PATH}.fetch_jira_objects")
374
+ @patch(f"{PATH}.get_regscale_data_and_attachments")
375
+ @patch(f"{PATH}.Api", return_value=MagicMock(spec=Api))
376
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
377
+ def test_sync_regscale_and_jira_updates(
378
+ self,
379
+ mock_check_license,
380
+ mock_api,
381
+ mock_get_regscale_data_and_attachments,
382
+ mock_fetch_jira_objects,
383
+ mock_create_jira_client,
384
+ mock_sync_regscale_to_jira,
385
+ mock_sync_regscale_objects_to_jira,
386
+ fetch_attachments,
387
+ ):
388
+ """Test sync_regscale_and_jira with updates from both sides"""
389
+ # mock jira client so we can check it was correctly used later
390
+ mock_jira_client = MagicMock()
391
+ mock_create_jira_client.return_value = mock_jira_client
392
+
393
+ # mock these so that we can control what objects were returned to check later
394
+ mock_fetch_jira_objects.return_value = MagicMock()
395
+ mock_get_regscale_data_and_attachments.return_value = (MagicMock(), MagicMock())
396
+
397
+ sync_regscale_and_jira(
398
+ parent_id=self.PARENT_ID,
399
+ parent_module=self.PARENT_MODULE,
400
+ jira_project=self.JIRA_PROJECT,
401
+ jira_issue_type="Bug",
402
+ sync_attachments=fetch_attachments,
403
+ )
404
+
405
+ # check that we get the jira client correctly
406
+ mock_create_jira_client.assert_called_once()
407
+
408
+ # check that we correctly fetch objects from jira and regscale
409
+ mock_get_regscale_data_and_attachments.assert_called_once_with(
410
+ parent_id=self.PARENT_ID,
411
+ parent_module=self.PARENT_MODULE,
412
+ sync_attachments=fetch_attachments,
413
+ sync_tasks_only=False,
414
+ )
415
+ mock_fetch_jira_objects.assert_called_once_with(
416
+ jira_client=mock_jira_client,
417
+ jira_project=self.JIRA_PROJECT,
418
+ jql_str="project = SNES",
419
+ jira_issue_type="Bug",
420
+ sync_tasks_only=False,
421
+ )
422
+
423
+ # check that updates were made with correct objects
424
+ mock_sync_regscale_to_jira.assert_called_once_with(
425
+ regscale_objects=mock_get_regscale_data_and_attachments.return_value[0],
426
+ jira_client=mock_jira_client,
427
+ jira_project=self.JIRA_PROJECT,
428
+ jira_issue_type="Bug",
429
+ api=mock_api.return_value,
430
+ sync_attachments=fetch_attachments,
431
+ attachments=mock_get_regscale_data_and_attachments.return_value[1],
432
+ )
433
+ mock_sync_regscale_objects_to_jira.assert_called_once_with(
434
+ mock_fetch_jira_objects.return_value,
435
+ mock_get_regscale_data_and_attachments.return_value[0],
436
+ fetch_attachments,
437
+ mock_check_license.return_value,
438
+ self.PARENT_ID,
439
+ self.PARENT_MODULE,
440
+ False,
441
+ )
442
+
443
+ @patch(f"{PATH}.sync_regscale_objects_to_jira")
444
+ @patch(f"{PATH}.sync_regscale_to_jira", return_value=[])
445
+ @patch(f"{PATH}.create_jira_client")
446
+ @patch(f"{PATH}.fetch_jira_objects")
447
+ @patch(f"{PATH}.get_regscale_data_and_attachments")
448
+ @patch(f"{PATH}.Api", return_value=MagicMock(spec=Api))
449
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
450
+ def test_sync_regscale_and_jira_custom_jql(
451
+ self,
452
+ mock_check_license,
453
+ mock_api,
454
+ mock_get_regscale_data_and_attachments,
455
+ mock_fetch_jira_objects,
456
+ mock_create_jira_client,
457
+ mock_sync_regscale_to_jira,
458
+ mock_sync_regscale_objects_to_jira,
459
+ fetch_attachments,
460
+ ):
461
+ """Test sync_regscale_and_jira with custom JQL query"""
462
+ # mock jira client so we can check it was correctly used later
463
+ mock_jira_client = MagicMock()
464
+ mock_create_jira_client.return_value = mock_jira_client
465
+
466
+ # mock these so that we can control what objects were returned to check later
467
+ mock_fetch_jira_objects.return_value = []
468
+ mock_get_regscale_data_and_attachments.return_value = ([], {})
469
+
470
+ custom_jql = "project = SNES AND assignee = currentUser() AND status != Closed"
471
+
472
+ sync_regscale_and_jira(
473
+ parent_id=self.PARENT_ID,
474
+ parent_module=self.PARENT_MODULE,
475
+ jira_project=self.JIRA_PROJECT,
476
+ jira_issue_type="Bug",
477
+ sync_attachments=fetch_attachments,
478
+ jql=custom_jql,
479
+ )
480
+
481
+ # check that we get the jira client correctly
482
+ mock_create_jira_client.assert_called_once()
483
+
484
+ # check that we correctly fetch objects from jira and regscale
485
+ mock_get_regscale_data_and_attachments.assert_called_once_with(
486
+ parent_id=self.PARENT_ID,
487
+ parent_module=self.PARENT_MODULE,
488
+ sync_attachments=fetch_attachments,
489
+ sync_tasks_only=False,
490
+ )
491
+ # Verify that the custom JQL was used instead of the default
492
+ mock_fetch_jira_objects.assert_called_once_with(
493
+ jira_client=mock_jira_client,
494
+ jira_project=self.JIRA_PROJECT,
495
+ jql_str=custom_jql,
496
+ jira_issue_type="Bug",
497
+ sync_tasks_only=False,
498
+ )
499
+
500
+ @patch(f"{PATH}.sync_regscale_objects_to_jira")
501
+ @patch(f"{PATH}.sync_regscale_to_jira", return_value=[])
502
+ @patch(f"{PATH}.create_jira_client")
503
+ @patch(f"{PATH}.fetch_jira_objects")
504
+ @patch(f"{PATH}.get_regscale_data_and_attachments")
505
+ @patch(f"{PATH}.Api", return_value=MagicMock(spec=Api))
506
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
507
+ def test_sync_regscale_and_jira_custom_jql_tasks(
508
+ self,
509
+ mock_check_license,
510
+ mock_api,
511
+ mock_get_regscale_data_and_attachments,
512
+ mock_fetch_jira_objects,
513
+ mock_create_jira_client,
514
+ mock_sync_regscale_to_jira,
515
+ mock_sync_regscale_objects_to_jira,
516
+ fetch_attachments,
517
+ ):
518
+ """Test sync_regscale_and_jira with custom JQL query for tasks"""
519
+ # mock jira client so we can check it was correctly used later
520
+ mock_jira_client = MagicMock()
521
+ mock_create_jira_client.return_value = mock_jira_client
522
+
523
+ # mock these so that we can control what objects were returned to check later
524
+ mock_fetch_jira_objects.return_value = []
525
+ mock_get_regscale_data_and_attachments.return_value = ([], {})
526
+
527
+ custom_jql = "project = SNES AND assignee = currentUser() AND issueType = Task"
528
+
529
+ sync_regscale_and_jira(
530
+ parent_id=self.PARENT_ID,
531
+ parent_module=self.PARENT_MODULE,
532
+ jira_project=self.JIRA_PROJECT,
533
+ jira_issue_type="Task",
534
+ sync_attachments=fetch_attachments,
535
+ sync_tasks_only=True,
536
+ jql=custom_jql,
537
+ )
538
+
539
+ # check that we get the jira client correctly
540
+ mock_create_jira_client.assert_called_once()
541
+
542
+ # check that we correctly fetch objects from jira and regscale
543
+ mock_get_regscale_data_and_attachments.assert_called_once_with(
544
+ parent_id=self.PARENT_ID,
545
+ parent_module=self.PARENT_MODULE,
546
+ sync_attachments=fetch_attachments,
547
+ sync_tasks_only=True,
548
+ )
549
+ # Verify that the custom JQL was used instead of the default task-specific JQL
550
+ mock_fetch_jira_objects.assert_called_once_with(
551
+ jira_client=mock_jira_client,
552
+ jira_project=self.JIRA_PROJECT,
553
+ jql_str=custom_jql,
554
+ jira_issue_type="Task",
555
+ sync_tasks_only=True,
556
+ )
557
+
558
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
559
+ def test_sync_regscale_objects_to_jira(
560
+ self, mock_check_license, fetch_attachments, get_jira_issue, regscale_issues_and_attachments
561
+ ):
562
+ """Test syncing RegScale objects to Jira"""
563
+ mock_check_license.return_value.config = self.config
564
+ try:
565
+ sync_regscale_objects_to_jira(
566
+ jira_issues=[get_jira_issue],
567
+ regscale_objects=regscale_issues_and_attachments[0],
568
+ sync_attachments=fetch_attachments,
569
+ app=self.app,
570
+ parent_id=self.PARENT_ID,
571
+ parent_module=self.PARENT_MODULE,
572
+ sync_tasks_only=False,
573
+ )
574
+ except Exception as e:
575
+ pytest.fail("Jira & RegScale task sync failed: {}".format(e))
576
+
577
+ @patch(f"{PATH}.create_jira_client")
578
+ @patch(f"{PATH}.create_and_update_regscale_issues")
579
+ @patch(f"{PATH}.create_and_update_regscale_tasks")
580
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
581
+ def test_sync_regscale_objects_to_jira_tasks_only(
582
+ self,
583
+ mock_check_license,
584
+ mock_create_and_update_regscale_tasks,
585
+ mock_create_and_update_regscale_issues,
586
+ mock_create_jira_client,
587
+ fetch_attachments,
588
+ ):
589
+ """Test syncing RegScale objects to Jira with sync_tasks_only=True"""
590
+ mock_check_license.return_value.config = self.config
591
+ mock_jira_client = MagicMock()
592
+ mock_create_jira_client.return_value = mock_jira_client
593
+ mock_create_and_update_regscale_tasks.return_value = (1, 0, 0)
594
+
595
+ sync_regscale_objects_to_jira(
596
+ MagicMock(),
597
+ MagicMock(),
598
+ fetch_attachments,
599
+ MagicMock(spec=Application),
600
+ self.PARENT_ID,
601
+ self.PARENT_MODULE,
602
+ True,
603
+ )
604
+
605
+ mock_create_and_update_regscale_tasks.assert_called_once()
606
+ mock_create_and_update_regscale_issues.assert_not_called()
607
+
608
+ @patch(f"{PATH}.create_jira_client")
609
+ @patch(f"{PATH}.create_threads")
610
+ @patch(f"{PATH}.create_and_update_regscale_tasks")
611
+ @patch(f"{PATH}.check_license", return_value=MagicMock(spec=Application))
612
+ def test_sync_regscale_objects_to_jira_all(
613
+ self,
614
+ mock_check_license,
615
+ mock_create_and_update_regscale_tasks,
616
+ mock_create_threads,
617
+ mock_create_jira_client,
618
+ fetch_attachments,
619
+ ):
620
+ """Test syncing RegScale objects to Jira with sync_tasks_only=False"""
621
+ mock_check_license.return_value.config = self.config
622
+ mock_jira_client = MagicMock()
623
+ mock_create_jira_client.return_value = mock_jira_client
624
+
625
+ sync_regscale_objects_to_jira(
626
+ MagicMock(),
627
+ MagicMock(),
628
+ fetch_attachments,
629
+ MagicMock(spec=Application),
630
+ self.PARENT_ID,
631
+ self.PARENT_MODULE,
632
+ False,
633
+ )
634
+
635
+ mock_create_and_update_regscale_tasks.assert_not_called()
636
+ mock_create_threads.assert_called_once()
637
+
638
+ @patch(f"{PATH}.JIRA")
639
+ def test_create_jira_client_basic(self, mock_jira):
640
+ """Test creating a Jira client"""
641
+ conf = {
642
+ "jiraUrl": "https://example.com",
643
+ "jiraApiToken": "token",
644
+ "jiraUserName": "user",
645
+ }
646
+ _ = create_jira_client(conf, token_auth=False)
647
+ mock_jira.assert_called_once_with(basic_auth=("user", "token"), options={"server": "https://example.com"})
648
+
649
+ @patch(f"{PATH}.JIRA")
650
+ def test_create_jira_client_token(self, mock_jira):
651
+ """Test creating a Jira client with token auth"""
652
+ from regscale.integrations.variables import ScannerVariables
653
+
654
+ conf = {
655
+ "jiraUrl": "https://example.com",
656
+ "jiraApiToken": "token",
657
+ "jiraUserName": "user",
658
+ }
659
+ _ = create_jira_client(conf, token_auth=True)
660
+ mock_jira.assert_called_once_with(
661
+ token_auth="token", options={"server": "https://example.com", "verify": ScannerVariables.sslVerify}
662
+ )
663
+
664
+ @staticmethod
665
+ def test_jira_issues(jira_issues, fetch_attachments):
666
+ """Test fetching Jira issues and creating a Jira client"""
667
+ has_attachments = None
668
+ if fetch_attachments:
669
+ assert jira_issues is not None
670
+ assert True in [True if issue.fields.attachment else False for issue in jira_issues]
671
+ else:
672
+ assert jira_issues is not None
673
+ try:
674
+ # try to access the attachment attribute
675
+ has_attachments = [True if issue.fields.attachment else False for issue in jira_issues]
676
+ assert len(has_attachments) == len(jira_issues)
677
+ except AttributeError:
678
+ # if the attribute doesn't exist, then we know there are no attachments
679
+ assert has_attachments is None
680
+
681
+ @staticmethod
682
+ def test_jira_tasks(jira_tasks, fetch_attachments):
683
+ """Test fetching Jira tasks and creating a Jira client"""
684
+ has_attachments = None
685
+ if fetch_attachments:
686
+ assert jira_tasks is not None
687
+ assert True in [True if task.fields.attachment else False for task in jira_tasks]
688
+ else:
689
+ assert jira_tasks is not None
690
+ try:
691
+ # try to access the attachment attribute
692
+ has_attachments = [True if task.fields.attachment else False for task in jira_tasks]
693
+ assert len(has_attachments) == len(jira_tasks)
694
+ except AttributeError:
695
+ # if the attribute doesn't exist, then we know there are no attachments
696
+ assert has_attachments is None
697
+
698
+ @staticmethod
699
+ def test_fetch_regscale_issues_and_attachments(regscale_issues_and_attachments, fetch_attachments):
700
+ """Test fetching RegScale issues and attachments"""
701
+ issues, attachments = regscale_issues_and_attachments
702
+ assert issues is not None
703
+ if fetch_attachments:
704
+ assert attachments is not None
705
+ else:
706
+ assert attachments == []
707
+
708
+ @staticmethod
709
+ def test_fetch_regscale_tasks_and_attachments(regscale_tasks_and_attachments, fetch_attachments):
710
+ """Test fetching RegScale tasks and attachments"""
711
+ tasks, attachments = regscale_tasks_and_attachments
712
+ assert tasks is not None
713
+ if fetch_attachments:
714
+ assert attachments != []
715
+ else:
716
+ assert attachments == []
717
+
718
+ @pytest.mark.parametrize(
719
+ "due_date,priority,expected_days",
720
+ [
721
+ ("2024-12-31", "High", 7), # Has due date
722
+ (None, "High", 7), # No due date, high priority
723
+ (None, "Medium", 14), # No due date, medium priority
724
+ (None, "Low", 30), # No due date, low priority
725
+ (None, None, 14), # No due date, no priority (defaults to medium)
726
+ ],
727
+ )
728
+ @patch(f"{PATH}.datetime")
729
+ def test_map_jira_due_date(self, mock_datetime, due_date, priority, expected_days):
730
+ """Test mapping Jira due dates to RegScale format"""
731
+ # Set up mock datetime to return a fixed date
732
+ fixed_date = datetime(2024, 1, 1, 12, 0, 0)
733
+ mock_datetime.now.return_value = fixed_date
734
+ mock_datetime.timedelta = timedelta # Allow timedelta to work normally
735
+
736
+ # Create mock Jira issue
737
+ mock_issue = MagicMock()
738
+ mock_issue.fields.duedate = due_date
739
+ if priority:
740
+ mock_issue.fields.priority = MagicMock()
741
+ mock_issue.fields.priority.name = priority
742
+ else:
743
+ mock_issue.fields.priority = None
744
+
745
+ # Create mock config
746
+ mock_config = {"issues": {"jira": {"high": 7, "medium": 14, "low": 30}}}
747
+
748
+ result = map_jira_due_date(mock_issue, mock_config)
749
+
750
+ if due_date:
751
+ assert result == due_date
752
+ else:
753
+ # Calculate expected date using the same fixed date
754
+ expected_date = fixed_date + timedelta(days=expected_days)
755
+ result_date = datetime.strptime(result, "%Y-%m-%d %H:%M:%S")
756
+ assert result_date.date() == expected_date.date()
757
+
758
+ @pytest.mark.parametrize(
759
+ "status,expected_status",
760
+ [
761
+ ("Done", "Closed"),
762
+ ("In Progress", "Open"),
763
+ ("To Do", "Open"),
764
+ ],
765
+ )
766
+ def test_map_jira_to_regscale_issue(self, status, expected_status):
767
+ """Test mapping Jira issues to RegScale format"""
768
+ # Create mock Jira issue
769
+ issue_status = MagicMock()
770
+ issue_status.name = status
771
+ mock_issue = MagicMock(
772
+ key="TEST-123",
773
+ fields=MagicMock(
774
+ summary="Skipped task",
775
+ description="Skipped task description",
776
+ status=issue_status,
777
+ duedate=None,
778
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
779
+ ),
780
+ )
781
+ mock_issue.fields.priority = MagicMock()
782
+ mock_issue.fields.priority.name = "High"
783
+
784
+ # Create mock config
785
+ mock_config = {
786
+ "userId": "1", # Convert to string to match Optional[str] type
787
+ "issues": {"jira": {"status": "Open", "high": 7, "medium": 14, "low": 30}},
788
+ }
789
+
790
+ result = map_jira_to_regscale_issue(mock_issue, mock_config, 1, "issues")
791
+
792
+ # Verify the Issue object was created with correct attributes
793
+ assert isinstance(result, Issue)
794
+ assert result.title == "Skipped task"
795
+ assert "Skipped task description" in result.description
796
+ assert result.status == expected_status
797
+ assert result.jiraId == "TEST-123"
798
+ assert result.parentId == 1
799
+ assert result.parentModule == "issues"
800
+ assert result.dueDate is not None # Due date will be calculated based on priority
801
+ if status == "Done":
802
+ assert result.dateCompleted is not None
803
+ else:
804
+ assert result.dateCompleted is None
805
+
806
+ @pytest.mark.parametrize(
807
+ "status,expected_status,expected_percent_complete,expected_date_closed",
808
+ [
809
+ ("Done", "Closed", 100, True),
810
+ ("In Progress", "Open", None, False),
811
+ ("To Do", "Backlog", None, False),
812
+ ],
813
+ )
814
+ def test_create_regscale_task_from_jira(
815
+ self, status, expected_status, expected_percent_complete, expected_date_closed
816
+ ):
817
+ """Test creating RegScale tasks from Jira issues"""
818
+ # Create mock Jira issue
819
+ mock_issue = MagicMock()
820
+ mock_issue.fields.summary = "Test Task"
821
+ mock_issue.fields.description = "Test Description"
822
+ mock_issue.fields.status.name = status
823
+ mock_issue.fields.duedate = "2024-12-31"
824
+ mock_issue.fields.statuscategorychangedate = "2024-01-01T12:00:00.000Z"
825
+ mock_issue.key = "TEST-123"
826
+
827
+ # Create mock config
828
+ mock_config = {"issues": {"jira": {"medium": 14}}}
829
+
830
+ result = create_regscale_task_from_jira(mock_config, mock_issue, 1, "issues")
831
+
832
+ assert result.title == "Test Task"
833
+ assert result.description == "Test Description"
834
+ assert result.status == expected_status
835
+ assert result.dueDate == "2024-12-31"
836
+ assert result.parentId == 1
837
+ assert result.parentModule == "issues"
838
+ assert result.otherIdentifier == "TEST-123"
839
+ if expected_percent_complete:
840
+ assert result.percentComplete == expected_percent_complete
841
+ if expected_date_closed:
842
+ assert result.dateClosed is not None
843
+ else:
844
+ assert result.dateClosed is None
845
+
846
+ def test_check_and_close_tasks(self):
847
+ """Test checking and closing tasks"""
848
+ jira_titles = {"Testing1234"}
849
+ tasks = [
850
+ Task(
851
+ id=3,
852
+ title="Different Title",
853
+ status="Backlog",
854
+ dueDate=get_current_datetime(),
855
+ dateClosed="",
856
+ percentComplete=0,
857
+ ),
858
+ Task(
859
+ id=4,
860
+ title="Testing1234",
861
+ status="Backlog",
862
+ dueDate=get_current_datetime(),
863
+ dateClosed="",
864
+ percentComplete=0,
865
+ ),
866
+ ]
867
+
868
+ closed_tasks = check_and_close_tasks(tasks, set(jira_titles))
869
+ assert len(closed_tasks) == 1
870
+ assert closed_tasks[0].status == "Closed"
871
+ assert closed_tasks[0].percentComplete == 100
872
+
873
+ def test_process_tasks_for_sync(self):
874
+ """Test processing tasks for sync"""
875
+ todo_status = MagicMock()
876
+ todo_status.name = "to do"
877
+ in_progress_status = MagicMock()
878
+ in_progress_status.name = "in progress"
879
+ done_status = MagicMock()
880
+ done_status.name = "done"
881
+ # Create mock Jira issues
882
+ jira_tasks = [
883
+ MagicMock( # should be skipped (up to date - nothing happens)
884
+ key="JIRA-1",
885
+ fields=MagicMock(
886
+ summary="Skipped task",
887
+ description="Skipped task description",
888
+ status=todo_status,
889
+ duedate=None,
890
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
891
+ priority=None,
892
+ ),
893
+ ),
894
+ MagicMock( # should be inserted (task in jira but not regscale)
895
+ key="JIRA-2",
896
+ fields=MagicMock(
897
+ summary="New task",
898
+ description="New task description",
899
+ status=todo_status,
900
+ duedate=None,
901
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
902
+ priority=None,
903
+ ),
904
+ ),
905
+ MagicMock( # should be updated (task in both but out of sync)
906
+ key="JIRA-3",
907
+ fields=MagicMock(
908
+ summary="Existing task",
909
+ description="Existing task description",
910
+ status=in_progress_status,
911
+ duedate=None,
912
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
913
+ priority=None,
914
+ ),
915
+ ),
916
+ MagicMock( # should be updated
917
+ key="JIRA-4",
918
+ fields=MagicMock(
919
+ summary="Existing task",
920
+ description="Existing task description",
921
+ status=todo_status,
922
+ duedate=None,
923
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
924
+ priority=None,
925
+ ),
926
+ ),
927
+ MagicMock( # should be closed
928
+ key="JIRA-5",
929
+ fields=MagicMock(
930
+ summary="Existing task",
931
+ description="Existing task description",
932
+ status=done_status,
933
+ duedate=None,
934
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
935
+ priority=None,
936
+ ),
937
+ ),
938
+ MagicMock( # date to be updated
939
+ key="JIRA-6",
940
+ fields=MagicMock(
941
+ summary="Existing task",
942
+ description="Existing task description",
943
+ status=todo_status,
944
+ duedate="2024-12-31",
945
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
946
+ priority=None,
947
+ ),
948
+ ),
949
+ ]
950
+ regscale_tasks = [
951
+ Task( # matches JIRA-1 (should be skipped - up to date)
952
+ id=1,
953
+ title="Skipped task",
954
+ status="Backlog",
955
+ description="Skipped task description",
956
+ otherIdentifier="JIRA-1",
957
+ parentId=self.PARENT_ID,
958
+ parentModule=self.PARENT_MODULE,
959
+ dueDate=get_current_datetime(),
960
+ ),
961
+ Task( # matches JIRA-3 (should be updated - task in both but out of sync)
962
+ id=3,
963
+ title="Existing task",
964
+ status="Open",
965
+ description="Different description", # Different description to show sync needed
966
+ otherIdentifier="JIRA-3",
967
+ parentId=self.PARENT_ID,
968
+ parentModule=self.PARENT_MODULE,
969
+ dueDate=get_current_datetime(),
970
+ ),
971
+ Task( # matches JIRA-4 (should be updated - regscale closed but jira open)
972
+ id=4,
973
+ title="Existing task",
974
+ status="Closed", # Closed in RegScale but open in Jira
975
+ description="Existing task description",
976
+ otherIdentifier="JIRA-4",
977
+ parentId=self.PARENT_ID,
978
+ parentModule=self.PARENT_MODULE,
979
+ dueDate=get_current_datetime(),
980
+ ),
981
+ Task( # matches JIRA-5 (should be closed - jira closed but regscale open)
982
+ id=5,
983
+ title="Existing task",
984
+ status="Open", # Open in RegScale but closed in Jira
985
+ description="Existing task description",
986
+ otherIdentifier="JIRA-5",
987
+ parentId=self.PARENT_ID,
988
+ parentModule=self.PARENT_MODULE,
989
+ dueDate=get_current_datetime(),
990
+ ),
991
+ Task(
992
+ id=6,
993
+ title="New task",
994
+ status="Backlog",
995
+ description="Not in jira",
996
+ otherIdentifier="JIRA-19",
997
+ parentId=self.PARENT_ID,
998
+ parentModule=self.PARENT_MODULE,
999
+ ),
1000
+ Task(
1001
+ id=7,
1002
+ title="Existing task",
1003
+ status="Backlog",
1004
+ description="Existing task description",
1005
+ otherIdentifier="JIRA-6",
1006
+ parentId=self.PARENT_ID,
1007
+ parentModule=self.PARENT_MODULE,
1008
+ dueDate="2023-01-01",
1009
+ ),
1010
+ ]
1011
+
1012
+ progress = MagicMock(spec=Progress)
1013
+ progress_task = MagicMock()
1014
+
1015
+ insert_tasks, update_tasks, close_tasks = process_tasks_for_sync(
1016
+ config=self.config,
1017
+ jira_issues=jira_tasks,
1018
+ existing_tasks=regscale_tasks,
1019
+ parent_id=self.PARENT_ID,
1020
+ parent_module=self.PARENT_MODULE,
1021
+ progress=progress,
1022
+ progress_task=progress_task,
1023
+ )
1024
+
1025
+ # Assertions
1026
+ assert len(insert_tasks) == 1 # New task should be inserted
1027
+ assert len(update_tasks) == 3 # No updates needed
1028
+ assert len(close_tasks) == 1 # Existing task should be closed
1029
+
1030
+ @patch(f"{PATH}.process_tasks_for_sync")
1031
+ @patch(f"{PATH}.task_and_attachments_sync")
1032
+ @patch("regscale.core.app.api.Api")
1033
+ @patch(f"{PATH}.ThreadPoolExecutor")
1034
+ @patch(f"{PATH}.as_completed")
1035
+ def test_create_and_update_regscale_tasks(
1036
+ self, mock_as_completed, mock_thread_pool, mock_api, mock_task_sync, mock_process_tasks_for_sync
1037
+ ):
1038
+ """Test creating and updating RegScale tasks from Jira tasks"""
1039
+ # Setup mock tasks
1040
+ insert_tasks = [
1041
+ Task(
1042
+ id=1,
1043
+ title="New task",
1044
+ status="Backlog",
1045
+ description="New task description",
1046
+ otherIdentifier="JIRA-19",
1047
+ parentId=self.PARENT_ID,
1048
+ parentModule=self.PARENT_MODULE,
1049
+ )
1050
+ ]
1051
+ update_tasks = [
1052
+ Task(
1053
+ id=2,
1054
+ title="Existing task",
1055
+ status="Open",
1056
+ description="Existing task description",
1057
+ otherIdentifier="JIRA-3",
1058
+ parentId=self.PARENT_ID,
1059
+ parentModule=self.PARENT_MODULE,
1060
+ )
1061
+ ]
1062
+ close_tasks = [
1063
+ Task(
1064
+ id=3,
1065
+ title="Existing task",
1066
+ status="Closed",
1067
+ description="Existing task description",
1068
+ otherIdentifier="JIRA-5",
1069
+ parentId=self.PARENT_ID,
1070
+ parentModule=self.PARENT_MODULE,
1071
+ )
1072
+ ]
1073
+ mock_process_tasks_for_sync.return_value = (insert_tasks, update_tasks, close_tasks)
1074
+
1075
+ mock_api_instance = MagicMock()
1076
+ mock_api.return_value = mock_api_instance
1077
+ mock_api_instance.app.config = self.config
1078
+
1079
+ # Setup mock thread pool
1080
+ mock_executor = MagicMock()
1081
+ mock_thread_pool.return_value.__enter__.return_value = mock_executor
1082
+
1083
+ # Setup task_and_attachments_sync to return None
1084
+ mock_task_sync.return_value = None
1085
+
1086
+ # Make as_completed return an empty iterator
1087
+ mock_as_completed.return_value = []
1088
+
1089
+ inserted, updated, closed = create_and_update_regscale_tasks(
1090
+ jira_issues=[],
1091
+ existing_tasks=[],
1092
+ jira_client=MagicMock(),
1093
+ parent_id=self.PARENT_ID,
1094
+ parent_module=self.PARENT_MODULE,
1095
+ progress=MagicMock(spec=Progress),
1096
+ progress_task=MagicMock(),
1097
+ )
1098
+
1099
+ assert inserted == 1
1100
+ assert updated == 1
1101
+ assert closed == 1
1102
+
1103
+ mock_thread_pool.assert_called_once_with(max_workers=10)
1104
+ assert mock_executor.submit.call_count == 3
1105
+
1106
+ @patch(f"{PATH}.compare_files_for_dupes_and_upload")
1107
+ def test_tasks_and_attachments_sync_create(self, mock_compare_files_for_dupes_and_upload):
1108
+ """Test performing create operation on tasks in task_and_attachments_sync"""
1109
+ # check if operation fails
1110
+ mock_task = MagicMock()
1111
+ mock_task.create.return_value = None
1112
+ task_and_attachments_sync(
1113
+ operation="create",
1114
+ task=mock_task,
1115
+ jira_client=MagicMock(),
1116
+ api=MagicMock(),
1117
+ )
1118
+ mock_task.create.assert_called_once()
1119
+ mock_compare_files_for_dupes_and_upload.assert_not_called()
1120
+
1121
+ # check if operation is successful
1122
+ mock_task.create.reset_mock()
1123
+ mock_task.create.return_value = MagicMock()
1124
+ task_and_attachments_sync(
1125
+ operation="create",
1126
+ task=mock_task,
1127
+ jira_client=MagicMock(),
1128
+ api=MagicMock(),
1129
+ )
1130
+ mock_task.create.assert_called_once()
1131
+ mock_compare_files_for_dupes_and_upload.assert_called_once()
1132
+
1133
+ @pytest.mark.parametrize("operation", ["update", "close"])
1134
+ @patch(f"{PATH}.compare_files_for_dupes_and_upload")
1135
+ def test_tasks_and_attachments_sync_save(self, mock_compare_files_for_dupes_and_upload, operation):
1136
+ """Test performing save operation on tasks in task_and_attachments_sync"""
1137
+ # check if operation fails
1138
+ mock_task = MagicMock()
1139
+ mock_task.save.return_value = None
1140
+ task_and_attachments_sync(
1141
+ operation=operation,
1142
+ task=mock_task,
1143
+ jira_client=MagicMock(),
1144
+ api=MagicMock(),
1145
+ )
1146
+ mock_task.save.assert_called_once()
1147
+ mock_compare_files_for_dupes_and_upload.assert_not_called()
1148
+
1149
+ # check if operation is successful
1150
+ mock_task.save.reset_mock()
1151
+ mock_task.save.return_value = MagicMock()
1152
+ task_and_attachments_sync(
1153
+ operation=operation,
1154
+ task=mock_task,
1155
+ jira_client=MagicMock(),
1156
+ api=MagicMock(),
1157
+ )
1158
+ mock_task.save.assert_called_once()
1159
+ mock_compare_files_for_dupes_and_upload.assert_called_once()
1160
+
1161
+ @patch(f"{PATH}.compare_files_for_dupes_and_upload")
1162
+ @patch(f"{PATH}.Issue.insert_issue")
1163
+ @patch(f"{PATH}.Issue.update_issue")
1164
+ @patch(f"{PATH}.job_progress", return_value=MagicMock(spec=Progress))
1165
+ def test_create_and_update_regscale_issues(
1166
+ self,
1167
+ mock_job_progress_object,
1168
+ mock_update_issue,
1169
+ mock_insert_issue,
1170
+ mock_compare_files_for_dupes_and_upload,
1171
+ fetch_attachments,
1172
+ ):
1173
+ """Test creating and updating RegScale issues from Jira issues"""
1174
+ open_status = MagicMock()
1175
+ open_status.name = "open"
1176
+ in_progress_status = MagicMock()
1177
+ in_progress_status.name = "in progress"
1178
+ closed_status = MagicMock()
1179
+ closed_status.name = "done"
1180
+
1181
+ highest_priority = MagicMock()
1182
+ highest_priority.name = "highest"
1183
+ high_priority = MagicMock()
1184
+ high_priority.name = "high"
1185
+ medium_priority = MagicMock()
1186
+ medium_priority.name = "medium"
1187
+ low_priority = MagicMock()
1188
+ low_priority.name = "low"
1189
+ lowest_priority = MagicMock()
1190
+ lowest_priority.name = "lowest"
1191
+
1192
+ # Create mock Jira issues
1193
+ jira_issues = [
1194
+ MagicMock( # should be skipped (up to date - nothing happens) - counts as updated
1195
+ key="JIRA-1",
1196
+ fields=MagicMock(
1197
+ summary="Skipped issue",
1198
+ description="Skipped issue description",
1199
+ status=open_status,
1200
+ duedate=None,
1201
+ priority=highest_priority,
1202
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1203
+ attachment=MagicMock(),
1204
+ ),
1205
+ ),
1206
+ MagicMock( # should be inserted (issue in jira but not regscale)
1207
+ key="JIRA-2",
1208
+ fields=MagicMock(
1209
+ summary="New issue",
1210
+ description="New issue description",
1211
+ status=open_status,
1212
+ duedate=None,
1213
+ priority=medium_priority,
1214
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1215
+ attachment=None,
1216
+ ),
1217
+ ),
1218
+ MagicMock( # should be updated (issue in both but out of sync)
1219
+ key="JIRA-3",
1220
+ fields=MagicMock(
1221
+ summary="Existing issue",
1222
+ description="Existing issue description",
1223
+ status=in_progress_status,
1224
+ duedate=None,
1225
+ priority=low_priority,
1226
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1227
+ attachment=None, # No attachments
1228
+ ),
1229
+ ),
1230
+ MagicMock( # should be closed - counts as updated
1231
+ key="JIRA-4",
1232
+ fields=MagicMock(
1233
+ summary="Existing issue",
1234
+ description="Existing issue description",
1235
+ status=closed_status,
1236
+ duedate=None,
1237
+ priority=lowest_priority,
1238
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1239
+ attachment=None, # No attachments
1240
+ ),
1241
+ ),
1242
+ ]
1243
+
1244
+ # Create RegScale issues
1245
+ regscale_issues = [
1246
+ Issue( # matches JIRA-1 (should be skipped - up to date)
1247
+ id=1,
1248
+ title="Skipped issue",
1249
+ status="Open",
1250
+ description="Skipped issue description",
1251
+ jiraId="JIRA-1",
1252
+ parentId=self.PARENT_ID,
1253
+ parentModule=self.PARENT_MODULE,
1254
+ dueDate=get_current_datetime(),
1255
+ ),
1256
+ Issue( # matches JIRA-3 (should be updated - issue in both but out of sync)
1257
+ id=3,
1258
+ title="Existing issue",
1259
+ status="Open",
1260
+ description="Different description", # Different description to show sync needed
1261
+ jiraId="JIRA-3",
1262
+ parentId=self.PARENT_ID,
1263
+ parentModule=self.PARENT_MODULE,
1264
+ dueDate=get_current_datetime(),
1265
+ ),
1266
+ Issue( # matches JIRA-4 (should be closed - jira closed but regscale open)
1267
+ id=4,
1268
+ title="Existing issue",
1269
+ status="Open", # Open in RegScale but closed in Jira
1270
+ description="Existing issue description",
1271
+ jiraId="JIRA-4",
1272
+ parentId=self.PARENT_ID,
1273
+ parentModule=self.PARENT_MODULE,
1274
+ dueDate=get_current_datetime(),
1275
+ ),
1276
+ ]
1277
+
1278
+ # Create mock config with priority mappings from init.yaml
1279
+ config = {
1280
+ "issues": {"jira": {"highest": 7, "high": 30, "medium": 90, "low": 180, "lowest": 365, "status": "Open"}},
1281
+ "maxThreads": 4, # Set to match number of issues to process
1282
+ "userId": "123e4567-e89b-12d3-a456-426614174000", # Add a fake UUID for userId for issue owner
1283
+ }
1284
+ app = MagicMock()
1285
+ app.config = config
1286
+
1287
+ # Setup mock return values
1288
+ mock_update_issue.return_value = MagicMock()
1289
+ mock_insert_issue.return_value = MagicMock()
1290
+
1291
+ with mock_job_progress_object as job_progress:
1292
+ test_task = job_progress.add_task(
1293
+ description="Processing issues",
1294
+ total=len(jira_issues),
1295
+ visible=False,
1296
+ )
1297
+
1298
+ args = (
1299
+ jira_issues, # Pass all issues at once
1300
+ regscale_issues,
1301
+ fetch_attachments,
1302
+ MagicMock(),
1303
+ app,
1304
+ self.PARENT_ID,
1305
+ self.PARENT_MODULE,
1306
+ test_task,
1307
+ job_progress,
1308
+ )
1309
+
1310
+ # Test each thread
1311
+ for thread in range(4):
1312
+ create_and_update_regscale_issues(
1313
+ args=args,
1314
+ thread=thread,
1315
+ )
1316
+
1317
+ assert mock_update_issue.call_count == 3 # JIRA-3, JIRA-4, and JIRA-1
1318
+ assert mock_insert_issue.call_count == 1 # JIRA-2
1319
+ if fetch_attachments:
1320
+ assert mock_compare_files_for_dupes_and_upload.call_count == 1 # JIRA-2
1321
+ else:
1322
+ assert mock_compare_files_for_dupes_and_upload.call_count == 0
1323
+
1324
+ @patch(f"{PATH}.create_issue_in_jira")
1325
+ def test_sync_regscale_issues_to_jira(self, mock_create_issue_in_jira, fetch_attachments):
1326
+ """Test inserting Regscale issues into jira if they do not exist"""
1327
+
1328
+ # Create RegScale issues
1329
+ regscale_objects = [
1330
+ Issue(
1331
+ id=1,
1332
+ title="Test Issue",
1333
+ status="Open",
1334
+ description="This is a test issue",
1335
+ dueDate=get_current_datetime(),
1336
+ parentId=self.PARENT_ID,
1337
+ parentModule=self.PARENT_MODULE,
1338
+ ),
1339
+ Issue(
1340
+ id=3,
1341
+ title="Test Issue with Jira ID",
1342
+ status="Open",
1343
+ description="This is a test issue with Jira ID",
1344
+ dueDate=get_current_datetime(),
1345
+ parentId=self.PARENT_ID,
1346
+ parentModule=self.PARENT_MODULE,
1347
+ jiraId="JIRA-3",
1348
+ ),
1349
+ ]
1350
+
1351
+ # Create mock Jira issues
1352
+ open_status = MagicMock()
1353
+ open_status.name = "open"
1354
+
1355
+ returned_jira_issues = [
1356
+ MagicMock(
1357
+ key="JIRA-1",
1358
+ fields=MagicMock(
1359
+ summary="Test Issue",
1360
+ description="This is a test issue",
1361
+ status=open_status,
1362
+ duedate=None,
1363
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1364
+ attachment=None,
1365
+ ),
1366
+ )
1367
+ ]
1368
+
1369
+ mock_create_issue_in_jira.side_effect = returned_jira_issues
1370
+
1371
+ new_regscale_objects = sync_regscale_to_jira(
1372
+ regscale_objects=regscale_objects,
1373
+ jira_client=MagicMock(),
1374
+ jira_project=self.JIRA_PROJECT,
1375
+ jira_issue_type="Issue", # Using Issue type for issues
1376
+ sync_attachments=fetch_attachments,
1377
+ attachments={},
1378
+ api=MagicMock(),
1379
+ )
1380
+
1381
+ assert len(new_regscale_objects) == 1 # Only one new issue should be created
1382
+ assert mock_create_issue_in_jira.call_count == 1 # Should only be called once for the issue without Jira ID
1383
+ assert new_regscale_objects[0].jiraId == "JIRA-1"
1384
+
1385
+ @patch(f"{PATH}.create_issue_in_jira")
1386
+ def test_sync_regscale_tasks_to_jira(self, mock_create_issue_in_jira, fetch_attachments):
1387
+ """Test inserting Regscale tasks into jira if they do not exist"""
1388
+
1389
+ # Create RegScale tasks
1390
+ regscale_objects = [
1391
+ Task(
1392
+ id=2,
1393
+ title="Test Task",
1394
+ status="Backlog",
1395
+ description="This is a test task",
1396
+ dueDate=get_current_datetime(),
1397
+ parentId=self.PARENT_ID,
1398
+ parentModule=self.PARENT_MODULE,
1399
+ ),
1400
+ Task(
1401
+ id=4,
1402
+ title="Test Task with Other ID",
1403
+ status="Backlog",
1404
+ description="This is a test task with other ID",
1405
+ dueDate=get_current_datetime(),
1406
+ parentId=self.PARENT_ID,
1407
+ parentModule=self.PARENT_MODULE,
1408
+ otherIdentifier="JIRA-4",
1409
+ ),
1410
+ ]
1411
+
1412
+ # Create mock Jira issues
1413
+ todo_status = MagicMock()
1414
+ todo_status.name = "to do"
1415
+
1416
+ returned_jira_issues = [
1417
+ MagicMock(
1418
+ key="JIRA-2",
1419
+ fields=MagicMock(
1420
+ summary="Test Task",
1421
+ description="This is a test task",
1422
+ status=todo_status,
1423
+ duedate=None,
1424
+ statuscategorychangedate="2025-06-12T12:46:34.755961+0000",
1425
+ attachment=None,
1426
+ ),
1427
+ )
1428
+ ]
1429
+
1430
+ mock_create_issue_in_jira.side_effect = returned_jira_issues
1431
+
1432
+ new_regscale_objects = sync_regscale_to_jira(
1433
+ regscale_objects=regscale_objects,
1434
+ jira_client=MagicMock(),
1435
+ jira_project=self.JIRA_PROJECT,
1436
+ jira_issue_type="Task", # Using Task type for tasks
1437
+ sync_attachments=fetch_attachments,
1438
+ attachments={},
1439
+ api=MagicMock(),
1440
+ )
1441
+
1442
+ assert len(new_regscale_objects) == 1 # Only one new task should be created
1443
+ assert mock_create_issue_in_jira.call_count == 1 # Should only be called once for the task without Jira ID
1444
+ assert new_regscale_objects[0].otherIdentifier == "JIRA-2"
1445
+
1446
+ def test_generate_jira_comment(self):
1447
+ """Test generating a Jira comment from a RegScale issue"""
1448
+ # Create issue with mix of included and excluded fields
1449
+ issue = Issue(
1450
+ id=1,
1451
+ title="Test Issue",
1452
+ status="Open",
1453
+ description="Test description",
1454
+ createdById="excluded-1",
1455
+ lastUpdatedById="excluded-2",
1456
+ issueOwnerId="excluded-3",
1457
+ assignedToId="excluded-4",
1458
+ uuid="excluded-5",
1459
+ jiraId="JIRA-123",
1460
+ severityLevel="High",
1461
+ dueDate="2023-12-31",
1462
+ )
1463
+
1464
+ comment = _generate_jira_comment(issue)
1465
+
1466
+ # Verify excluded fields are not in comment
1467
+ assert "createdById" not in comment
1468
+ assert "lastUpdatedById" not in comment
1469
+ assert "issueOwnerId" not in comment
1470
+ assert "assignedToId" not in comment
1471
+ assert "uuid" not in comment
1472
+
1473
+ # Verify included fields are in comment
1474
+ assert "**jiraId:** JIRA-123" in comment
1475
+ assert "**severityLevel:** High" in comment
1476
+ assert "**dueDate:** 2023-12-31" in comment
1477
+ assert "**title:** Test Issue" in comment
1478
+ assert "**status:** Open" in comment
1479
+ assert "**description:** Test description" in comment
1480
+
1481
+ def test_generate_jira_comment_task(self):
1482
+ """Test generating a Jira comment from a RegScale task"""
1483
+ # Create task with mix of included and excluded fields
1484
+ task = Task(
1485
+ id=1,
1486
+ title="Test Task",
1487
+ status="Backlog",
1488
+ description="Test task description",
1489
+ createdById="excluded-1",
1490
+ lastUpdatedById="excluded-2",
1491
+ assignedToId="excluded-3",
1492
+ uuid="excluded-4",
1493
+ otherIdentifier="JIRA-123",
1494
+ percentComplete=50,
1495
+ dueDate="2023-12-31",
1496
+ )
1497
+
1498
+ comment = _generate_jira_comment(task)
1499
+
1500
+ # Verify excluded fields are not in comment
1501
+ assert "createdById" not in comment
1502
+ assert "lastUpdatedById" not in comment
1503
+ assert "assignedToId" not in comment
1504
+ assert "uuid" not in comment
1505
+
1506
+ # Verify included fields are in comment
1507
+ assert "**otherIdentifier:** JIRA-123" in comment
1508
+ assert "**percentComplete:** 50" in comment
1509
+ assert "**dueDate:** 2023-12-31" in comment
1510
+ assert "**title:** Test Task" in comment
1511
+ assert "**status:** Backlog" in comment
1512
+ assert "**description:** Test task description" in comment
1513
+
1514
+ def test_create_issue_in_jira(self, regscale_issues_and_attachments, jira_client, fetch_attachments):
1515
+ """Test creating an issue in Jira"""
1516
+ issues, attachments = regscale_issues_and_attachments
1517
+ for issue in issues:
1518
+ jira_issue = create_issue_in_jira(
1519
+ regscale_object=issue,
1520
+ jira_client=jira_client,
1521
+ jira_project=self.JIRA_PROJECT,
1522
+ issue_type="Bug",
1523
+ add_attachments=fetch_attachments,
1524
+ attachments=attachments,
1525
+ api=self.api,
1526
+ )
1527
+
1528
+ assert jira_issue is not None
1529
+ assert jira_issue.key is not None
1530
+ assert jira_issue.fields.summary == issue.title
1531
+ assert issue.description in jira_issue.fields.description
1532
+
1533
+ jira_issue.delete() # cleanup issue in jira
1534
+
1535
+ def test_create_task_in_jira(self, regscale_tasks_and_attachments, jira_client, fetch_attachments):
1536
+ """Test creating a task in Jira"""
1537
+ tasks, attachments = regscale_tasks_and_attachments
1538
+ for task in tasks:
1539
+ jira_task = create_issue_in_jira(
1540
+ regscale_object=task,
1541
+ jira_client=jira_client,
1542
+ jira_project=self.JIRA_PROJECT,
1543
+ issue_type="Task",
1544
+ add_attachments=fetch_attachments,
1545
+ attachments=attachments,
1546
+ api=self.api,
1547
+ )
1548
+
1549
+ assert jira_task is not None
1550
+ assert jira_task.key is not None
1551
+ assert jira_task.fields.summary == task.title
1552
+ assert task.description in jira_task.fields.description
1553
+
1554
+ jira_task.delete() # cleanup task in jira
1555
+
1556
+ def test_create_issue_in_jira_error(self):
1557
+ """Test that we exit when Jira API call fails"""
1558
+ # Create a mock Jira client that will raise an error
1559
+ mock_jira_client = MagicMock()
1560
+ mock_jira_client.create_issue.side_effect = JIRAError("Test error")
1561
+
1562
+ mock_regscale = MagicMock()
1563
+ mock_regscale.get_module_string.return_value = "issues"
1564
+ mock_regscale.id = 1
1565
+
1566
+ mock_api = MagicMock()
1567
+ mock_api.config = {"domain": "https://test.regscale.com"}
1568
+
1569
+ with pytest.raises(SystemExit) as e:
1570
+ create_issue_in_jira(
1571
+ regscale_object=mock_regscale,
1572
+ jira_client=mock_jira_client,
1573
+ jira_project=self.JIRA_PROJECT,
1574
+ issue_type="Bug",
1575
+ add_attachments=True,
1576
+ attachments={},
1577
+ api=mock_api,
1578
+ )
1579
+ assert e.value.code == 1
1580
+ assert e.type == SystemExit
1581
+
1582
+ def test_upload_files_to_jira(self, jira_client):
1583
+ """Test uploading files to Jira"""
1584
+ # create a jira issue to upload attachment to
1585
+ jira_issue = jira_client.create_issue(
1586
+ project=self.JIRA_PROJECT,
1587
+ summary="Test Issue",
1588
+ description="Test Description",
1589
+ issuetype={"name": "Bug"},
1590
+ )
1591
+
1592
+ # create regscale issue to link to
1593
+ reg_issue = Issue(
1594
+ id=1,
1595
+ title="Test Issue",
1596
+ description="Test Description",
1597
+ parentId=self.PARENT_ID,
1598
+ parentModule=self.PARENT_MODULE,
1599
+ )
1600
+
1601
+ # setup file hashes for upload
1602
+ file_path = os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "attachment.txt")
1603
+ with open(file_path, "rb") as file:
1604
+ reg_hashes = {compute_hash(file): file_path}
1605
+ jira_hashes = {}
1606
+ uploaded_attachments = []
1607
+
1608
+ upload_files_to_jira(
1609
+ jira_hashes,
1610
+ reg_hashes,
1611
+ jira_issue,
1612
+ reg_issue,
1613
+ jira_client,
1614
+ uploaded_attachments,
1615
+ )
1616
+ assert uploaded_attachments == [file_path]
1617
+ check_issue = jira_client.issue(jira_issue.key)
1618
+ assert len(check_issue.fields.attachment) == 1
1619
+ assert check_issue.fields.attachment[0].size > 0
1620
+ assert check_issue.fields.attachment[0].created is not None
1621
+
1622
+ # Clean up
1623
+ jira_issue.delete()
1624
+
1625
+ def test_upload_files_to_jira_duplicates(self):
1626
+ """Test that we don't upload duplicates"""
1627
+ jira_hashes = {"dummyhash": "dummy/path/file.txt"}
1628
+ reg_hashes = {"dummyhash": "dummy/path/file.txt"}
1629
+ jira_issue = MagicMock()
1630
+ reg_issue = MagicMock()
1631
+ jira_client = MagicMock()
1632
+ uploaded_attachments = []
1633
+
1634
+ upload_files_to_jira(jira_hashes, reg_hashes, jira_issue, reg_issue, jira_client, uploaded_attachments)
1635
+
1636
+ assert uploaded_attachments == []
1637
+ jira_client.add_attachment.assert_not_called()
1638
+
1639
+ @pytest.mark.parametrize("error_type", [JIRAError, TypeError])
1640
+ @patch(f"{PATH}.open")
1641
+ def test_upload_files_to_jira_error(self, mock_open, error_type):
1642
+ """Test that uploads aren't made when errors are encountered"""
1643
+ # Setup mock file
1644
+ mock_file = MagicMock()
1645
+ mock_file.read.return_value = b"test content"
1646
+ mock_open.return_value.__enter__.return_value = mock_file
1647
+
1648
+ # Setup test data
1649
+ file_path = "dummy/path/file.txt"
1650
+ reg_hashes = {"dummyhash": file_path}
1651
+ jira_hashes = {}
1652
+ jira_issue = MagicMock()
1653
+ reg_issue = MagicMock()
1654
+ jira_client = MagicMock()
1655
+ jira_client.add_attachment.side_effect = error_type("Test error")
1656
+ uploaded_attachments = []
1657
+
1658
+ upload_files_to_jira(jira_hashes, reg_hashes, jira_issue, reg_issue, jira_client, uploaded_attachments)
1659
+
1660
+ assert uploaded_attachments == []
1661
+ jira_client.add_attachment.assert_called_once()
1662
+ mock_file.read.assert_called_once()
1663
+
1664
+ def test_upload_files_to_regscale(self):
1665
+ """Test uploading files to RegScale"""
1666
+ tmp = Issue(
1667
+ id=1,
1668
+ title="Test Issue",
1669
+ description="Test Description",
1670
+ parentId=self.PARENT_ID,
1671
+ parentModule=self.PARENT_MODULE,
1672
+ dueDate=get_current_datetime(),
1673
+ status="Open",
1674
+ )
1675
+ reg_issue = tmp.create()
1676
+
1677
+ file_path = os.path.join(self.get_tests_dir("tests"), "test_data", "jira_attachments", "attachment.txt")
1678
+ with open(file_path, "rb") as file:
1679
+ jira_hashes = {compute_hash(file): file_path}
1680
+ reg_hashes = {}
1681
+ uploaded_attachments = []
1682
+
1683
+ upload_files_to_regscale(jira_hashes, reg_hashes, reg_issue, self.api, uploaded_attachments)
1684
+
1685
+ assert uploaded_attachments == [file_path]
1686
+ check_issues, attachments = Issue.get_objects_and_attachments_by_parent(
1687
+ parent_id=self.PARENT_ID, parent_module=self.PARENT_MODULE
1688
+ )
1689
+ assert reg_issue in check_issues
1690
+ assert len(attachments[reg_issue.id]) == 1
1691
+
1692
+ @patch(f"{PATH}.File.upload_file_to_regscale", return_value=None)
1693
+ def test_upload_files_to_regscale_duplicates(self, mock_upload_file_to_regscale):
1694
+ """Test that we don't upload duplicate attachments to regscale"""
1695
+ jira_hashes = {"dummyhash": "dummy/path/file.txt"}
1696
+ reg_hashes = {"dummyhash": "dummy/path/file.txt"}
1697
+ reg_issue = MagicMock()
1698
+ uploaded_attachments = []
1699
+
1700
+ upload_files_to_regscale(jira_hashes, reg_hashes, reg_issue, MagicMock(), uploaded_attachments)
1701
+
1702
+ assert uploaded_attachments == []
1703
+ mock_upload_file_to_regscale.assert_not_called()
1704
+
1705
+ @patch(f"{PATH}.File.upload_file_to_regscale", return_value=None)
1706
+ @patch(f"{PATH}.open")
1707
+ def test_upload_files_to_regscale_error(self, mock_open, mock_upload_file_to_regscale):
1708
+ """Test when the uploads are unsuccessful"""
1709
+ mock_file = MagicMock()
1710
+ mock_file.read.return_value = b"test content"
1711
+ mock_open.return_value.__enter__.return_value = mock_file
1712
+
1713
+ # Setup test data
1714
+ file_path = "dummy/path/file.txt"
1715
+ jira_hashes = {"dummyhash": file_path}
1716
+ reg_hashes = {}
1717
+ reg_issue = MagicMock()
1718
+ uploaded_attachments = []
1719
+ api = MagicMock()
1720
+
1721
+ upload_files_to_regscale(jira_hashes, reg_hashes, reg_issue, api, uploaded_attachments)
1722
+
1723
+ assert uploaded_attachments == []
1724
+ mock_upload_file_to_regscale.assert_called_once()
1725
+
1726
+ def test_validate_issue_type(self):
1727
+ """Test validating the issue type"""
1728
+ jira_client = MagicMock()
1729
+ issue_type_bug = MagicMock()
1730
+ issue_type_bug.name = "Bug"
1731
+ issue_type_task = MagicMock()
1732
+ issue_type_task.name = "Task"
1733
+ jira_client.issue_types.return_value = [issue_type_bug, issue_type_task]
1734
+ assert validate_issue_type(jira_client, "Bug") is True
1735
+ assert validate_issue_type(jira_client, "Task") is True
1736
+ with pytest.raises(SystemExit) as e:
1737
+ validate_issue_type(jira_client, "Invalid")
1738
+ assert e.value.code == 1
1739
+ assert e.type == SystemExit
1740
+
1741
+ def test_download_issue_attachments(self, regscale_issues_and_attachments, get_jira_issue_with_attachment):
1742
+ """Test downloading attachments from Jira and RegScale issues"""
1743
+ issues, attachments = regscale_issues_and_attachments
1744
+ jira_issue = get_jira_issue_with_attachment
1745
+ if attachments:
1746
+ with tempfile.TemporaryDirectory() as tmpdir:
1747
+ download_regscale_attachments_to_directory(
1748
+ directory=tmpdir,
1749
+ jira_issue=jira_issue,
1750
+ regscale_object=issues[0],
1751
+ api=self.api,
1752
+ )
1753
+ assert os.path.exists(tmpdir) is True
1754
+ with tempfile.TemporaryDirectory() as tmpdir:
1755
+ download_regscale_attachments_to_directory(
1756
+ directory=tmpdir,
1757
+ jira_issue=jira_issue,
1758
+ regscale_object=issues[0],
1759
+ api=self.api,
1760
+ )
1761
+ assert os.path.exists(tmpdir) is True
1762
+
1763
+ def test_download_task_attachments(self, regscale_tasks_and_attachments, get_jira_task_with_attachment):
1764
+ """Test downloading attachments from Jira and RegScale tasks"""
1765
+ tasks, attachments = regscale_tasks_and_attachments
1766
+ jira_task = get_jira_task_with_attachment
1767
+ if attachments:
1768
+ with tempfile.TemporaryDirectory() as tmpdir:
1769
+ download_regscale_attachments_to_directory(
1770
+ directory=tmpdir,
1771
+ jira_issue=jira_task,
1772
+ regscale_object=tasks[0],
1773
+ api=self.api,
1774
+ )
1775
+ assert os.path.exists(tmpdir) is True
1776
+ with tempfile.TemporaryDirectory() as tmpdir:
1777
+ download_regscale_attachments_to_directory(
1778
+ directory=tmpdir,
1779
+ jira_issue=jira_task,
1780
+ regscale_object=tasks[0],
1781
+ api=self.api,
1782
+ )
1783
+ assert os.path.exists(tmpdir) is True
1784
+
1785
+ @patch(f"{PATH}.job_progress", return_value=MagicMock(spec=Progress))
1786
+ def test_update_regscale_issue(self, mock_job_progress_object, regscale_issue_and_attachment):
1787
+ """Test updating an issue in RegScale"""
1788
+ test_issue = regscale_issue_and_attachment
1789
+ # update fields in a RegScale issue
1790
+ test_issue.assetIdentifier = f"Updated via Test on {get_current_datetime()}"
1791
+ test_issue.lastUpdatedById = self.config["userId"]
1792
+ with mock_job_progress_object as job_progress:
1793
+ test_task = job_progress.add_task(
1794
+ description="Updating 1 issue(s) in RegScale...",
1795
+ total=len([test_issue]),
1796
+ visible=False,
1797
+ )
1798
+ update_regscale_issues(
1799
+ args=([test_issue], test_task),
1800
+ thread=0,
1801
+ )
1802
+ # make sure the task was marked complete
1803
+ assert test_task.finished
1804
+ # make sure the issue was updated
1805
+ updated_issue = Issue.fetch_issue_by_id(app=self.app, issue_id=test_issue.id)
1806
+ assert updated_issue.assetIdentifier == test_issue.assetIdentifier
1807
+ assert updated_issue.lastUpdatedById == test_issue.lastUpdatedById
1808
+
1809
+ @staticmethod
1810
+ def teardown_class(cls):
1811
+ """Remove test data"""
1812
+ with contextlib.suppress(FileNotFoundError):
1813
+ shutil.rmtree("./artifacts")
1814
+ assert not os.path.exists("./artifacts")