cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.43__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.
Files changed (135) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/logger.py +2 -2
  3. cumulusci/cli/service.py +20 -0
  4. cumulusci/cli/task.py +19 -3
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_org.py +5 -0
  8. cumulusci/cli/tests/test_service.py +15 -12
  9. cumulusci/cli/tests/test_task.py +122 -2
  10. cumulusci/cli/tests/utils.py +1 -4
  11. cumulusci/core/config/__init__.py +1 -0
  12. cumulusci/core/config/base_task_flow_config.py +26 -1
  13. cumulusci/core/config/org_config.py +2 -1
  14. cumulusci/core/config/project_config.py +14 -20
  15. cumulusci/core/config/scratch_org_config.py +12 -0
  16. cumulusci/core/config/tests/test_config.py +1 -0
  17. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  18. cumulusci/core/config/universal_config.py +3 -4
  19. cumulusci/core/dependencies/base.py +5 -1
  20. cumulusci/core/dependencies/dependencies.py +1 -1
  21. cumulusci/core/dependencies/github.py +1 -2
  22. cumulusci/core/dependencies/resolvers.py +1 -1
  23. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  24. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  25. cumulusci/core/flowrunner.py +90 -6
  26. cumulusci/core/github.py +1 -1
  27. cumulusci/core/sfdx.py +3 -1
  28. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  29. cumulusci/core/source_transforms/transforms.py +1 -1
  30. cumulusci/core/tasks.py +13 -2
  31. cumulusci/core/tests/test_flowrunner.py +100 -0
  32. cumulusci/core/tests/test_tasks.py +65 -0
  33. cumulusci/core/utils.py +3 -1
  34. cumulusci/core/versions.py +1 -1
  35. cumulusci/cumulusci.yml +73 -1
  36. cumulusci/oauth/client.py +1 -1
  37. cumulusci/plugins/plugin_base.py +5 -3
  38. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  39. cumulusci/salesforce_api/rest_deploy.py +1 -1
  40. cumulusci/schema/cumulusci.jsonschema.json +69 -0
  41. cumulusci/tasks/apex/anon.py +1 -1
  42. cumulusci/tasks/apex/testrunner.py +421 -144
  43. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  44. cumulusci/tasks/bulkdata/extract.py +0 -1
  45. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  46. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  47. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  48. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  49. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  50. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  51. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  52. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  53. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  54. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  55. cumulusci/tasks/bulkdata/tests/test_select_utils.py +46 -0
  56. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  57. cumulusci/tasks/create_package_version.py +190 -16
  58. cumulusci/tasks/datadictionary.py +1 -1
  59. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  60. cumulusci/tasks/metadata_etl/applications.py +256 -0
  61. cumulusci/tasks/metadata_etl/base.py +7 -3
  62. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  63. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  64. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  65. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  66. cumulusci/tasks/push/README.md +15 -17
  67. cumulusci/tasks/release_notes/README.md +13 -13
  68. cumulusci/tasks/release_notes/generator.py +13 -8
  69. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  70. cumulusci/tasks/salesforce/Deploy.py +53 -2
  71. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  72. cumulusci/tasks/salesforce/__init__.py +1 -0
  73. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  74. cumulusci/tasks/salesforce/composite.py +1 -1
  75. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  76. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  77. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  78. cumulusci/tasks/salesforce/insert_record.py +18 -19
  79. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  80. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  81. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  82. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  83. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  84. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  85. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  86. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  87. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +1427 -0
  88. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  89. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  90. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  91. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  92. cumulusci/tasks/salesforce/update_external_credential.py +647 -0
  93. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  94. cumulusci/tasks/salesforce/update_profile.py +17 -13
  95. cumulusci/tasks/salesforce/update_record.py +217 -0
  96. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  97. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  98. cumulusci/tasks/sfdmu/__init__.py +0 -0
  99. cumulusci/tasks/sfdmu/sfdmu.py +376 -0
  100. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  101. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  102. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  103. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  104. cumulusci/tasks/tests/test_util.py +42 -0
  105. cumulusci/tasks/util.py +37 -1
  106. cumulusci/tasks/utility/copyContents.py +402 -0
  107. cumulusci/tasks/utility/credentialManager.py +302 -0
  108. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  109. cumulusci/tasks/utility/env_management.py +1 -1
  110. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  111. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  112. cumulusci/tasks/utility/tests/test_credentialManager.py +1150 -0
  113. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  114. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1118 -0
  115. cumulusci/tests/test_integration_infrastructure.py +3 -1
  116. cumulusci/tests/test_utils.py +70 -6
  117. cumulusci/utils/__init__.py +54 -9
  118. cumulusci/utils/classutils.py +5 -2
  119. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  120. cumulusci/utils/options.py +23 -1
  121. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  122. cumulusci/utils/yaml/cumulusci_yml.py +8 -3
  123. cumulusci/utils/yaml/model_parser.py +2 -2
  124. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  125. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  126. cumulusci/vcs/base.py +23 -15
  127. cumulusci/vcs/bootstrap.py +5 -4
  128. cumulusci/vcs/utils/list_modified_files.py +189 -0
  129. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  130. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +11 -10
  131. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +135 -104
  132. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
  133. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
  134. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
  135. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1118 @@
1
+ """Tests for secretsToEnv module."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ import pytest
9
+
10
+ from cumulusci.core.config import TaskConfig
11
+ from cumulusci.tasks.utility.credentialManager import DevEnvironmentVariableProvider
12
+ from cumulusci.tasks.utility.secretsToEnv import SecretsToEnv
13
+
14
+
15
+ class TestSecretsToEnvOptions:
16
+ """Test cases for SecretsToEnv options configuration."""
17
+
18
+ def test_default_options(self):
19
+ """Test initialization with default options."""
20
+ task_config = TaskConfig({"options": {"secrets": ["TEST_SECRET"]}})
21
+ with tempfile.TemporaryDirectory() as tmpdir:
22
+ env_path = os.path.join(tmpdir, ".env")
23
+ task_config.options["env_path"] = env_path
24
+
25
+ task = SecretsToEnv(
26
+ project_config=mock.Mock(),
27
+ task_config=task_config,
28
+ org_config=None,
29
+ )
30
+
31
+ assert task.parsed_options.env_path == Path(env_path)
32
+ assert task.parsed_options.secrets_provider is None
33
+ assert task.parsed_options.provider_options == {}
34
+ assert task.parsed_options.secrets == ["TEST_SECRET"]
35
+
36
+ def test_custom_options(self):
37
+ """Test initialization with custom options."""
38
+ task_config = TaskConfig(
39
+ {
40
+ "options": {
41
+ "env_path": ".custom.env",
42
+ "secrets_provider": "environment",
43
+ "provider_options": {"key_prefix": "CUSTOM_"},
44
+ "secrets": ["API_KEY", "DB_PASSWORD"],
45
+ }
46
+ }
47
+ )
48
+
49
+ task = SecretsToEnv(
50
+ project_config=mock.Mock(),
51
+ task_config=task_config,
52
+ org_config=None,
53
+ )
54
+
55
+ assert task.parsed_options.env_path == Path(".custom.env")
56
+ assert task.parsed_options.secrets_provider == "environment"
57
+ assert task.parsed_options.provider_options == {"key_prefix": "CUSTOM_"}
58
+ assert task.parsed_options.secrets == ["API_KEY", "DB_PASSWORD"]
59
+
60
+ def test_secrets_as_mapping(self):
61
+ """Test initialization with secrets as mapping."""
62
+ task_config = TaskConfig(
63
+ {
64
+ "options": {
65
+ "env_path": ".env",
66
+ "secrets": {"DB_URL": "database_url", "API_KEY": "api_key"},
67
+ }
68
+ }
69
+ )
70
+
71
+ task = SecretsToEnv(
72
+ project_config=mock.Mock(),
73
+ task_config=task_config,
74
+ org_config=None,
75
+ )
76
+
77
+ assert task.parsed_options.secrets == {
78
+ "DB_URL": "database_url",
79
+ "API_KEY": "api_key",
80
+ }
81
+
82
+
83
+ class TestSecretsToEnvInitialization:
84
+ """Test cases for SecretsToEnv initialization methods."""
85
+
86
+ def test_init_options_creates_provider(self):
87
+ """Test that _init_options creates the correct provider."""
88
+ task_config = TaskConfig(
89
+ {
90
+ "options": {
91
+ "env_path": ".env",
92
+ "secrets_provider": "local",
93
+ "secrets": ["TEST_SECRET"],
94
+ }
95
+ }
96
+ )
97
+
98
+ task = SecretsToEnv(
99
+ project_config=mock.Mock(),
100
+ task_config=task_config,
101
+ org_config=None,
102
+ )
103
+
104
+ assert task.provider is not None
105
+ assert isinstance(task.provider, DevEnvironmentVariableProvider)
106
+
107
+ def test_init_options_loads_existing_env_file(self):
108
+ """Test that _init_options loads existing .env file."""
109
+ with tempfile.TemporaryDirectory() as tmpdir:
110
+ env_path = os.path.join(tmpdir, ".env")
111
+
112
+ # Create existing .env file
113
+ with open(env_path, "w") as f:
114
+ f.write('EXISTING_KEY="existing_value"\n')
115
+
116
+ task_config = TaskConfig(
117
+ {"options": {"env_path": env_path, "secrets": ["TEST_SECRET"]}}
118
+ )
119
+
120
+ task = SecretsToEnv(
121
+ project_config=mock.Mock(),
122
+ task_config=task_config,
123
+ org_config=None,
124
+ )
125
+
126
+ assert "EXISTING_KEY" in task.env_values
127
+ assert task.env_values["EXISTING_KEY"] == "existing_value"
128
+
129
+ def test_init_secrets_with_list_of_strings(self):
130
+ """Test _init_secrets with list of strings."""
131
+ task_config = TaskConfig(
132
+ {
133
+ "options": {
134
+ "env_path": ".env",
135
+ "secrets": ["API_KEY", "DB_PASSWORD", "TOKEN"],
136
+ }
137
+ }
138
+ )
139
+
140
+ task = SecretsToEnv(
141
+ project_config=mock.Mock(),
142
+ task_config=task_config,
143
+ org_config=None,
144
+ )
145
+
146
+ task._init_secrets()
147
+
148
+ # Should convert list to mapping with same key and value
149
+ assert task.secrets == {
150
+ "API_KEY": "API_KEY",
151
+ "DB_PASSWORD": "DB_PASSWORD",
152
+ "TOKEN": "TOKEN",
153
+ }
154
+
155
+ def test_init_secrets_with_mapping_format_in_list(self):
156
+ """Test _init_secrets with mapping format in list (key:value)."""
157
+ task_config = TaskConfig(
158
+ {
159
+ "options": {
160
+ "env_path": ".env",
161
+ "secrets": ["DB_URL:database_url", "API_KEY:api_key"],
162
+ }
163
+ }
164
+ )
165
+
166
+ task = SecretsToEnv(
167
+ project_config=mock.Mock(),
168
+ task_config=task_config,
169
+ org_config=None,
170
+ )
171
+
172
+ task._init_secrets()
173
+
174
+ # Should parse as mapping
175
+ assert task.secrets == {
176
+ "DB_URL": "database_url",
177
+ "API_KEY": "api_key",
178
+ }
179
+
180
+ def test_init_secrets_with_empty_list(self):
181
+ """Test _init_secrets with empty list doesn't initialize secrets."""
182
+ task_config = TaskConfig(
183
+ {
184
+ "options": {
185
+ "env_path": ".env",
186
+ "secrets": [],
187
+ }
188
+ }
189
+ )
190
+
191
+ task = SecretsToEnv(
192
+ project_config=mock.Mock(),
193
+ task_config=task_config,
194
+ org_config=None,
195
+ )
196
+
197
+ task._init_secrets()
198
+
199
+ # Should not set secrets attribute if list is empty
200
+ assert task.secrets == {}
201
+
202
+
203
+ class TestSecretsToEnvGetCredential:
204
+ """Test cases for _get_credential method."""
205
+
206
+ def test_get_credential_success(self):
207
+ """Test _get_credential successfully retrieves credential."""
208
+ task_config = TaskConfig(
209
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
210
+ )
211
+
212
+ task = SecretsToEnv(
213
+ project_config=mock.Mock(),
214
+ task_config=task_config,
215
+ org_config=None,
216
+ )
217
+
218
+ mock_provider = mock.Mock()
219
+ mock_provider.provider_type = "local"
220
+ mock_provider.get_credentials.return_value = "secret_value_123"
221
+ task.provider = mock_provider
222
+
223
+ safe_value, original_value = task._get_credential(
224
+ "API_KEY", "api_key", secret_name="my-secret"
225
+ )
226
+
227
+ assert safe_value == "secret_value_123"
228
+ assert original_value == "secret_value_123"
229
+ mock_provider.get_credentials.assert_called_once_with(
230
+ "API_KEY", {"value": "api_key", "secret_name": "my-secret"}
231
+ )
232
+
233
+ def test_get_credential_escapes_quotes(self):
234
+ """Test _get_credential escapes double quotes."""
235
+ task_config = TaskConfig(
236
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
237
+ )
238
+
239
+ task = SecretsToEnv(
240
+ project_config=mock.Mock(),
241
+ task_config=task_config,
242
+ org_config=None,
243
+ )
244
+
245
+ mock_provider = mock.Mock()
246
+ mock_provider.provider_type = "local"
247
+ mock_provider.get_credentials.return_value = 'value_with_"quotes"'
248
+ task.provider = mock_provider
249
+
250
+ safe_value, _ = task._get_credential("API_KEY", "api_key")
251
+
252
+ assert safe_value == 'value_with_\\"quotes\\"'
253
+
254
+ def test_get_credential_escapes_newlines(self):
255
+ """Test _get_credential escapes newlines."""
256
+ task_config = TaskConfig(
257
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
258
+ )
259
+
260
+ task = SecretsToEnv(
261
+ project_config=mock.Mock(),
262
+ task_config=task_config,
263
+ org_config=None,
264
+ )
265
+
266
+ mock_provider = mock.Mock()
267
+ mock_provider.provider_type = "local"
268
+ mock_provider.get_credentials.return_value = "line1\nline2\nline3"
269
+ task.provider = mock_provider
270
+
271
+ safe_value, _ = task._get_credential("API_KEY", "api_key")
272
+
273
+ assert safe_value == "line1\\nline2\\nline3"
274
+
275
+ def test_get_credential_handles_both_quotes_and_newlines(self):
276
+ """Test _get_credential handles both quotes and newlines."""
277
+ task_config = TaskConfig(
278
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
279
+ )
280
+
281
+ task = SecretsToEnv(
282
+ project_config=mock.Mock(),
283
+ task_config=task_config,
284
+ org_config=None,
285
+ )
286
+
287
+ mock_provider = mock.Mock()
288
+ mock_provider.provider_type = "local"
289
+ mock_provider.get_credentials.return_value = 'line1 "quoted"\nline2'
290
+ task.provider = mock_provider
291
+
292
+ safe_value, _ = task._get_credential("API_KEY", "api_key")
293
+
294
+ assert safe_value == 'line1 \\"quoted\\"\\nline2'
295
+
296
+ def test_get_credential_with_none_value_raises_error(self):
297
+ """Test _get_credential raises error when provider returns None."""
298
+ task_config = TaskConfig(
299
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
300
+ )
301
+
302
+ task = SecretsToEnv(
303
+ project_config=mock.Mock(),
304
+ task_config=task_config,
305
+ org_config=None,
306
+ )
307
+
308
+ mock_provider = mock.Mock()
309
+ mock_provider.provider_type = "local"
310
+ mock_provider.get_credentials.return_value = None
311
+ task.provider = mock_provider
312
+
313
+ with pytest.raises(ValueError) as exc_info:
314
+ task._get_credential("API_KEY", "api_key")
315
+
316
+ assert "Failed to retrieve secret API_KEY from local" in str(exc_info.value)
317
+
318
+ def test_get_credential_uses_env_key_parameter(self):
319
+ """Test _get_credential uses custom env_key when provided."""
320
+ task_config = TaskConfig(
321
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
322
+ )
323
+
324
+ task = SecretsToEnv(
325
+ project_config=mock.Mock(),
326
+ task_config=task_config,
327
+ org_config=None,
328
+ )
329
+
330
+ mock_provider = mock.Mock()
331
+ mock_provider.provider_type = "local"
332
+ mock_provider.get_credentials.return_value = "secret_value"
333
+ task.provider = mock_provider
334
+
335
+ safe_value, _ = task._get_credential(
336
+ "CREDENTIAL_KEY", "value", env_key="CUSTOM_ENV_KEY"
337
+ )
338
+
339
+ assert safe_value == "secret_value"
340
+
341
+ def test_get_credential_logs_masked_value(self, caplog):
342
+ """Test _get_credential logs masked value."""
343
+ import logging
344
+
345
+ caplog.set_level(logging.INFO)
346
+ task_config = TaskConfig(
347
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
348
+ )
349
+
350
+ task = SecretsToEnv(
351
+ project_config=mock.Mock(),
352
+ task_config=task_config,
353
+ org_config=None,
354
+ )
355
+
356
+ mock_provider = mock.Mock()
357
+ mock_provider.provider_type = "local"
358
+ mock_provider.get_credentials.return_value = "secret_value"
359
+ task.provider = mock_provider
360
+
361
+ task._get_credential("API_KEY", "api_key")
362
+
363
+ # Check that the log contains masked value
364
+ assert "API_KEY=*****" in caplog.text
365
+
366
+
367
+ class TestSecretsToEnvGetAllCredentials:
368
+ """Test cases for _get_all_credentials method."""
369
+
370
+ def test_get_all_credentials_success(self):
371
+ """Test _get_all_credentials successfully retrieves all credentials."""
372
+ task_config = TaskConfig(
373
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
374
+ )
375
+
376
+ task = SecretsToEnv(
377
+ project_config=mock.Mock(),
378
+ task_config=task_config,
379
+ org_config=None,
380
+ )
381
+
382
+ mock_provider = mock.Mock()
383
+ mock_provider.provider_type = "aws_secrets"
384
+ mock_provider.get_all_credentials.return_value = {
385
+ "API_KEY": "api_value",
386
+ "DB_PASSWORD": "db_pass",
387
+ "TOKEN": "token_value",
388
+ }
389
+ task.provider = mock_provider
390
+
391
+ result = task._get_all_credentials("*", secret_name="my-app/secrets")
392
+
393
+ assert result == {
394
+ "API_KEY": "api_value",
395
+ "DB_PASSWORD": "db_pass",
396
+ "TOKEN": "token_value",
397
+ }
398
+ mock_provider.get_all_credentials.assert_called_once_with(
399
+ "*", {"secret_name": "my-app/secrets"}
400
+ )
401
+
402
+ def test_get_all_credentials_escapes_quotes(self):
403
+ """Test _get_all_credentials escapes quotes in all values."""
404
+ task_config = TaskConfig(
405
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
406
+ )
407
+
408
+ task = SecretsToEnv(
409
+ project_config=mock.Mock(),
410
+ task_config=task_config,
411
+ org_config=None,
412
+ )
413
+
414
+ mock_provider = mock.Mock()
415
+ mock_provider.provider_type = "aws_secrets"
416
+ mock_provider.get_all_credentials.return_value = {
417
+ "KEY1": 'value_with_"quotes"',
418
+ "KEY2": "normal_value",
419
+ }
420
+ task.provider = mock_provider
421
+
422
+ result = task._get_all_credentials("*")
423
+
424
+ assert result["KEY1"] == 'value_with_\\"quotes\\"'
425
+ assert result["KEY2"] == "normal_value"
426
+
427
+ def test_get_all_credentials_escapes_newlines(self):
428
+ """Test _get_all_credentials escapes newlines in all values."""
429
+ task_config = TaskConfig(
430
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
431
+ )
432
+
433
+ task = SecretsToEnv(
434
+ project_config=mock.Mock(),
435
+ task_config=task_config,
436
+ org_config=None,
437
+ )
438
+
439
+ mock_provider = mock.Mock()
440
+ mock_provider.provider_type = "aws_secrets"
441
+ mock_provider.get_all_credentials.return_value = {
442
+ "KEY1": "line1\nline2",
443
+ "KEY2": "single_line",
444
+ }
445
+ task.provider = mock_provider
446
+
447
+ result = task._get_all_credentials("*")
448
+
449
+ assert result["KEY1"] == "line1\\nline2"
450
+ assert result["KEY2"] == "single_line"
451
+
452
+ def test_get_all_credentials_with_none_value_raises_error(self):
453
+ """Test _get_all_credentials raises error when provider returns None."""
454
+ task_config = TaskConfig(
455
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
456
+ )
457
+
458
+ task = SecretsToEnv(
459
+ project_config=mock.Mock(),
460
+ task_config=task_config,
461
+ org_config=None,
462
+ )
463
+
464
+ mock_provider = mock.Mock()
465
+ mock_provider.provider_type = "aws_secrets"
466
+ mock_provider.get_all_credentials.return_value = None
467
+ task.provider = mock_provider
468
+
469
+ with pytest.raises(ValueError) as exc_info:
470
+ task._get_all_credentials("*", secret_name="my-secret")
471
+
472
+ assert "Failed to retrieve secret *(my-secret) from aws_secrets" in str(
473
+ exc_info.value
474
+ )
475
+
476
+ def test_get_all_credentials_logs_masked_values(self, caplog):
477
+ """Test _get_all_credentials logs masked values for all keys."""
478
+ import logging
479
+
480
+ caplog.set_level(logging.INFO)
481
+ task_config = TaskConfig(
482
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
483
+ )
484
+
485
+ task = SecretsToEnv(
486
+ project_config=mock.Mock(),
487
+ task_config=task_config,
488
+ org_config=None,
489
+ )
490
+
491
+ mock_provider = mock.Mock()
492
+ mock_provider.provider_type = "aws_secrets"
493
+ mock_provider.get_all_credentials.return_value = {
494
+ "API_KEY": "api_value",
495
+ "DB_PASSWORD": "db_pass",
496
+ }
497
+ task.provider = mock_provider
498
+
499
+ task._get_all_credentials("*")
500
+
501
+ # Check that logs contain masked values
502
+ assert "API_KEY=*****" in caplog.text
503
+ assert "DB_PASSWORD=*****" in caplog.text
504
+
505
+
506
+ class TestSecretsToEnvRunTask:
507
+ """Test cases for _run_task method."""
508
+
509
+ def test_run_task_with_simple_secrets(self):
510
+ """Test _run_task with simple list of secrets."""
511
+ with tempfile.TemporaryDirectory() as tmpdir:
512
+ env_path = os.path.join(tmpdir, ".env")
513
+
514
+ task_config = TaskConfig(
515
+ {
516
+ "options": {
517
+ "env_path": env_path,
518
+ "secrets": ["API_KEY", "DB_PASSWORD"],
519
+ }
520
+ }
521
+ )
522
+
523
+ project_config = mock.Mock()
524
+ project_config.repo_root = tmpdir
525
+
526
+ task = SecretsToEnv(
527
+ project_config=project_config,
528
+ task_config=task_config,
529
+ org_config=None,
530
+ )
531
+
532
+ mock_provider = mock.Mock()
533
+ mock_provider.provider_type = "local"
534
+ mock_provider.get_credentials.side_effect = [
535
+ "api_secret_123",
536
+ "db_pass_456",
537
+ ]
538
+ task.provider = mock_provider
539
+
540
+ task()
541
+
542
+ # Verify file was created
543
+ assert os.path.exists(env_path)
544
+
545
+ # Verify file contents
546
+ with open(env_path, "r") as f:
547
+ content = f.read()
548
+
549
+ assert 'API_KEY="api_secret_123"' in content
550
+ assert 'DB_PASSWORD="db_pass_456"' in content
551
+
552
+ def test_run_task_with_wildcard_secret(self):
553
+ """Test _run_task with wildcard to get all secrets."""
554
+ with tempfile.TemporaryDirectory() as tmpdir:
555
+ env_path = os.path.join(tmpdir, ".env")
556
+
557
+ task_config = TaskConfig(
558
+ {
559
+ "options": {
560
+ "env_path": env_path,
561
+ "secrets": {"*": "my-app/secrets"},
562
+ }
563
+ }
564
+ )
565
+
566
+ project_config = mock.Mock()
567
+ project_config.repo_root = tmpdir
568
+
569
+ task = SecretsToEnv(
570
+ project_config=project_config,
571
+ task_config=task_config,
572
+ org_config=None,
573
+ )
574
+
575
+ # When secrets is a dict, _init_secrets doesn't set task.secrets
576
+ # so we need to set it manually for this test
577
+ task.secrets = task.parsed_options.secrets
578
+
579
+ mock_provider = mock.Mock()
580
+ mock_provider.provider_type = "aws_secrets"
581
+ mock_provider.get_all_credentials.return_value = {
582
+ "API_KEY": "api_value",
583
+ "DB_PASSWORD": "db_pass",
584
+ "TOKEN": "token_value",
585
+ }
586
+ task.provider = mock_provider
587
+
588
+ task()
589
+
590
+ # Verify file was created
591
+ assert os.path.exists(env_path)
592
+
593
+ # Verify file contents
594
+ with open(env_path, "r") as f:
595
+ content = f.read()
596
+
597
+ assert 'API_KEY="api_value"' in content
598
+ assert 'DB_PASSWORD="db_pass"' in content
599
+ assert 'TOKEN="token_value"' in content
600
+
601
+ def test_run_task_creates_directory_if_not_exists(self):
602
+ """Test _run_task creates parent directory if it doesn't exist."""
603
+ with tempfile.TemporaryDirectory() as tmpdir:
604
+ env_path = os.path.join(tmpdir, "subdir", "nested", ".env")
605
+
606
+ task_config = TaskConfig(
607
+ {
608
+ "options": {
609
+ "env_path": env_path,
610
+ "secrets": ["API_KEY"],
611
+ }
612
+ }
613
+ )
614
+
615
+ project_config = mock.Mock()
616
+ project_config.repo_root = tmpdir
617
+
618
+ task = SecretsToEnv(
619
+ project_config=project_config,
620
+ task_config=task_config,
621
+ org_config=None,
622
+ )
623
+
624
+ mock_provider = mock.Mock()
625
+ mock_provider.provider_type = "local"
626
+ mock_provider.get_credentials.return_value = "api_secret"
627
+ task.provider = mock_provider
628
+
629
+ task()
630
+
631
+ # Verify directory and file were created
632
+ assert os.path.exists(os.path.dirname(env_path))
633
+ assert os.path.exists(env_path)
634
+
635
+ def test_run_task_preserves_existing_env_values(self):
636
+ """Test _run_task preserves existing environment variables."""
637
+ with tempfile.TemporaryDirectory() as tmpdir:
638
+ env_path = os.path.join(tmpdir, ".env")
639
+
640
+ # Create existing .env file
641
+ with open(env_path, "w") as f:
642
+ f.write('EXISTING_KEY="existing_value"\n')
643
+ f.write('ANOTHER_KEY="another_value"\n')
644
+
645
+ task_config = TaskConfig(
646
+ {
647
+ "options": {
648
+ "env_path": env_path,
649
+ "secrets": ["NEW_SECRET"],
650
+ }
651
+ }
652
+ )
653
+
654
+ project_config = mock.Mock()
655
+ project_config.repo_root = tmpdir
656
+
657
+ task = SecretsToEnv(
658
+ project_config=project_config,
659
+ task_config=task_config,
660
+ org_config=None,
661
+ )
662
+
663
+ mock_provider = mock.Mock()
664
+ mock_provider.provider_type = "local"
665
+ mock_provider.get_credentials.return_value = "new_secret_value"
666
+ task.provider = mock_provider
667
+
668
+ task()
669
+
670
+ # Verify file contents
671
+ with open(env_path, "r") as f:
672
+ content = f.read()
673
+
674
+ assert 'EXISTING_KEY="existing_value"' in content
675
+ assert 'ANOTHER_KEY="another_value"' in content
676
+ assert 'NEW_SECRET="new_secret_value"' in content
677
+
678
+ def test_run_task_overwrites_duplicate_keys(self):
679
+ """Test _run_task overwrites duplicate keys with new values."""
680
+ with tempfile.TemporaryDirectory() as tmpdir:
681
+ env_path = os.path.join(tmpdir, ".env")
682
+
683
+ # Create existing .env file with key to be overwritten
684
+ with open(env_path, "w") as f:
685
+ f.write('API_KEY="old_value"\n')
686
+
687
+ task_config = TaskConfig(
688
+ {
689
+ "options": {
690
+ "env_path": env_path,
691
+ "secrets": ["API_KEY"],
692
+ }
693
+ }
694
+ )
695
+
696
+ project_config = mock.Mock()
697
+ project_config.repo_root = tmpdir
698
+
699
+ task = SecretsToEnv(
700
+ project_config=project_config,
701
+ task_config=task_config,
702
+ org_config=None,
703
+ )
704
+
705
+ mock_provider = mock.Mock()
706
+ mock_provider.provider_type = "local"
707
+ mock_provider.get_credentials.return_value = "new_value"
708
+ task.provider = mock_provider
709
+
710
+ task()
711
+
712
+ # Verify file contents
713
+ with open(env_path, "r") as f:
714
+ content = f.read()
715
+
716
+ assert 'API_KEY="new_value"' in content
717
+ assert 'API_KEY="old_value"' not in content
718
+
719
+ def test_run_task_with_mixed_secrets_and_wildcard(self):
720
+ """Test _run_task with combination of specific secrets and wildcard."""
721
+ with tempfile.TemporaryDirectory() as tmpdir:
722
+ env_path = os.path.join(tmpdir, ".env")
723
+
724
+ task_config = TaskConfig(
725
+ {
726
+ "options": {
727
+ "env_path": env_path,
728
+ "secrets": {
729
+ "*": "my-app/secrets",
730
+ "SPECIFIC_KEY": "specific_value",
731
+ },
732
+ }
733
+ }
734
+ )
735
+
736
+ project_config = mock.Mock()
737
+ project_config.repo_root = tmpdir
738
+
739
+ task = SecretsToEnv(
740
+ project_config=project_config,
741
+ task_config=task_config,
742
+ org_config=None,
743
+ )
744
+
745
+ # When secrets is a dict, _init_secrets doesn't set task.secrets
746
+ # so we need to set it manually for this test
747
+ task.secrets = task.parsed_options.secrets
748
+
749
+ mock_provider = mock.Mock()
750
+ mock_provider.provider_type = "aws_secrets"
751
+
752
+ def get_credentials_side_effect(key, options):
753
+ if key == "SPECIFIC_KEY":
754
+ return "specific_secret"
755
+ return None
756
+
757
+ mock_provider.get_credentials.side_effect = get_credentials_side_effect
758
+ mock_provider.get_all_credentials.return_value = {
759
+ "WILDCARD_KEY1": "wildcard_value1",
760
+ "WILDCARD_KEY2": "wildcard_value2",
761
+ }
762
+ task.provider = mock_provider
763
+
764
+ task()
765
+
766
+ # Verify file contents
767
+ with open(env_path, "r") as f:
768
+ content = f.read()
769
+
770
+ assert 'WILDCARD_KEY1="wildcard_value1"' in content
771
+ assert 'WILDCARD_KEY2="wildcard_value2"' in content
772
+ assert 'SPECIFIC_KEY="specific_secret"' in content
773
+
774
+ def test_run_task_creates_env_in_current_directory(self):
775
+ """Test _run_task creates .env in current directory when dirname is empty."""
776
+ with tempfile.TemporaryDirectory() as tmpdir:
777
+ env_path = ".env" # No directory component
778
+
779
+ task_config = TaskConfig(
780
+ {
781
+ "options": {
782
+ "env_path": env_path,
783
+ "secrets": ["API_KEY"],
784
+ }
785
+ }
786
+ )
787
+
788
+ project_config = mock.Mock()
789
+ project_config.repo_root = tmpdir
790
+
791
+ task = SecretsToEnv(
792
+ project_config=project_config,
793
+ task_config=task_config,
794
+ org_config=None,
795
+ )
796
+
797
+ mock_provider = mock.Mock()
798
+ mock_provider.provider_type = "local"
799
+ mock_provider.get_credentials.return_value = "api_secret"
800
+ task.provider = mock_provider
801
+
802
+ task()
803
+
804
+ # Verify file was created in current directory
805
+ full_path = os.path.join(tmpdir, env_path)
806
+ assert os.path.exists(full_path)
807
+
808
+
809
+ class TestSecretsToEnvIntegration:
810
+ """Integration tests for SecretsToEnv with different providers."""
811
+
812
+ @mock.patch.dict(os.environ, {"TEST_API_KEY": "env_api_secret"})
813
+ def test_integration_with_environment_provider(self):
814
+ """Test full workflow with environment provider."""
815
+ with tempfile.TemporaryDirectory() as tmpdir:
816
+ env_path = os.path.join(tmpdir, ".env")
817
+
818
+ task_config = TaskConfig(
819
+ {
820
+ "options": {
821
+ "env_path": env_path,
822
+ "secrets_provider": "environment",
823
+ "provider_options": {"key_prefix": "TEST_"},
824
+ "secrets": ["API_KEY"],
825
+ }
826
+ }
827
+ )
828
+
829
+ project_config = mock.Mock()
830
+ project_config.repo_root = tmpdir
831
+
832
+ task = SecretsToEnv(
833
+ project_config=project_config,
834
+ task_config=task_config,
835
+ org_config=None,
836
+ )
837
+
838
+ task()
839
+
840
+ # Verify file contents
841
+ with open(env_path, "r") as f:
842
+ content = f.read()
843
+
844
+ assert 'API_KEY="env_api_secret"' in content
845
+
846
+ def test_integration_with_local_provider(self):
847
+ """Test full workflow with local provider."""
848
+ with tempfile.TemporaryDirectory() as tmpdir:
849
+ env_path = os.path.join(tmpdir, ".env")
850
+
851
+ task_config = TaskConfig(
852
+ {
853
+ "options": {
854
+ "env_path": env_path,
855
+ "secrets_provider": "local",
856
+ "secrets": ["API_KEY", "DB_PASS"],
857
+ }
858
+ }
859
+ )
860
+
861
+ project_config = mock.Mock()
862
+ project_config.repo_root = tmpdir
863
+
864
+ task = SecretsToEnv(
865
+ project_config=project_config,
866
+ task_config=task_config,
867
+ org_config=None,
868
+ )
869
+
870
+ task()
871
+
872
+ # Verify file contents
873
+ # Local provider returns the key itself as the value
874
+ with open(env_path, "r") as f:
875
+ content = f.read()
876
+
877
+ assert 'API_KEY="API_KEY"' in content
878
+ assert 'DB_PASS="DB_PASS"' in content
879
+
880
+ def test_integration_with_aws_provider(self):
881
+ """Test full workflow with AWS Secrets Manager provider."""
882
+ import json
883
+ import sys
884
+
885
+ mock_client = mock.Mock()
886
+ mock_session = mock.Mock()
887
+ mock_session.client.return_value = mock_client
888
+ mock_boto3 = mock.Mock()
889
+ mock_boto3.session.Session.return_value = mock_session
890
+
891
+ secret_data = {"API_KEY": "aws_api_value", "DB_PASSWORD": "aws_db_pass"}
892
+ mock_client.get_secret_value.return_value = {
893
+ "SecretString": json.dumps(secret_data)
894
+ }
895
+
896
+ with tempfile.TemporaryDirectory() as tmpdir:
897
+ env_path = os.path.join(tmpdir, ".env")
898
+
899
+ task_config = TaskConfig(
900
+ {
901
+ "options": {
902
+ "env_path": env_path,
903
+ "secrets_provider": "aws_secrets",
904
+ "provider_options": {"aws_region": "us-east-1"},
905
+ "secrets": {"*": "my-app/secrets"},
906
+ }
907
+ }
908
+ )
909
+
910
+ project_config = mock.Mock()
911
+ project_config.repo_root = tmpdir
912
+
913
+ with mock.patch.dict(
914
+ sys.modules, {"boto3": mock_boto3, "botocore.exceptions": mock.Mock()}
915
+ ):
916
+ task = SecretsToEnv(
917
+ project_config=project_config,
918
+ task_config=task_config,
919
+ org_config=None,
920
+ )
921
+
922
+ # When secrets is a dict, _init_secrets doesn't set task.secrets
923
+ # so we need to set it manually for this test
924
+ task.secrets = task.parsed_options.secrets
925
+
926
+ task()
927
+
928
+ # Verify file contents
929
+ with open(env_path, "r") as f:
930
+ content = f.read()
931
+
932
+ assert 'API_KEY="aws_api_value"' in content
933
+ assert 'DB_PASSWORD="aws_db_pass"' in content
934
+
935
+ @mock.patch.dict(os.environ, {"MYAPP_API_TOKEN": "ado_token_value"})
936
+ def test_integration_with_ado_provider(self):
937
+ """Test full workflow with Azure DevOps variables provider."""
938
+ with tempfile.TemporaryDirectory() as tmpdir:
939
+ env_path = os.path.join(tmpdir, ".env")
940
+
941
+ task_config = TaskConfig(
942
+ {
943
+ "options": {
944
+ "env_path": env_path,
945
+ "secrets_provider": "ado_variables",
946
+ "provider_options": {"key_prefix": "MYAPP_"},
947
+ "secrets": ["API_TOKEN"],
948
+ }
949
+ }
950
+ )
951
+
952
+ project_config = mock.Mock()
953
+ project_config.repo_root = tmpdir
954
+
955
+ task = SecretsToEnv(
956
+ project_config=project_config,
957
+ task_config=task_config,
958
+ org_config=None,
959
+ )
960
+
961
+ task()
962
+
963
+ # Verify file contents
964
+ with open(env_path, "r") as f:
965
+ content = f.read()
966
+
967
+ assert 'API_TOKEN="ado_token_value"' in content
968
+
969
+
970
+ class TestSecretsToEnvEdgeCases:
971
+ """Test edge cases and error conditions."""
972
+
973
+ def test_empty_secrets_list_creates_empty_env_file(self):
974
+ """Test that empty secrets list still creates/updates env file."""
975
+ with tempfile.TemporaryDirectory() as tmpdir:
976
+ env_path = os.path.join(tmpdir, ".env")
977
+
978
+ # Create existing .env file
979
+ with open(env_path, "w") as f:
980
+ f.write('EXISTING="value"\n')
981
+
982
+ task_config = TaskConfig(
983
+ {
984
+ "options": {
985
+ "env_path": env_path,
986
+ "secrets": [],
987
+ }
988
+ }
989
+ )
990
+
991
+ project_config = mock.Mock()
992
+ project_config.repo_root = tmpdir
993
+
994
+ task = SecretsToEnv(
995
+ project_config=project_config,
996
+ task_config=task_config,
997
+ org_config=None,
998
+ )
999
+
1000
+ # When secrets is an empty list, _init_secrets doesn't set task.secrets
1001
+ # so we need to set it manually for this test
1002
+ task.secrets = {}
1003
+
1004
+ task()
1005
+
1006
+ # Verify existing content is preserved
1007
+ with open(env_path, "r") as f:
1008
+ content = f.read()
1009
+
1010
+ assert 'EXISTING="value"' in content
1011
+
1012
+ def test_special_characters_in_secret_values(self):
1013
+ """Test handling of special characters in secret values."""
1014
+ with tempfile.TemporaryDirectory() as tmpdir:
1015
+ env_path = os.path.join(tmpdir, ".env")
1016
+
1017
+ task_config = TaskConfig(
1018
+ {
1019
+ "options": {
1020
+ "env_path": env_path,
1021
+ "secrets": ["SPECIAL"],
1022
+ }
1023
+ }
1024
+ )
1025
+
1026
+ project_config = mock.Mock()
1027
+ project_config.repo_root = tmpdir
1028
+
1029
+ task = SecretsToEnv(
1030
+ project_config=project_config,
1031
+ task_config=task_config,
1032
+ org_config=None,
1033
+ )
1034
+
1035
+ mock_provider = mock.Mock()
1036
+ mock_provider.provider_type = "local"
1037
+ mock_provider.get_credentials.return_value = (
1038
+ "value!@#$%^&*()[]{}|\\;':<>?,./~`"
1039
+ )
1040
+ task.provider = mock_provider
1041
+
1042
+ task()
1043
+
1044
+ # Verify file can be read
1045
+ assert os.path.exists(env_path)
1046
+
1047
+ def test_unicode_characters_in_secret_values(self):
1048
+ """Test handling of unicode characters in secret values."""
1049
+ with tempfile.TemporaryDirectory() as tmpdir:
1050
+ env_path = os.path.join(tmpdir, ".env")
1051
+
1052
+ task_config = TaskConfig(
1053
+ {
1054
+ "options": {
1055
+ "env_path": env_path,
1056
+ "secrets": ["UNICODE"],
1057
+ }
1058
+ }
1059
+ )
1060
+
1061
+ project_config = mock.Mock()
1062
+ project_config.repo_root = tmpdir
1063
+
1064
+ task = SecretsToEnv(
1065
+ project_config=project_config,
1066
+ task_config=task_config,
1067
+ org_config=None,
1068
+ )
1069
+
1070
+ mock_provider = mock.Mock()
1071
+ mock_provider.provider_type = "local"
1072
+ mock_provider.get_credentials.return_value = "Hello 世界 🌍 Привет"
1073
+ task.provider = mock_provider
1074
+
1075
+ task()
1076
+
1077
+ # Verify file contents
1078
+ with open(env_path, "r", encoding="utf-8") as f:
1079
+ content = f.read()
1080
+
1081
+ assert 'UNICODE="Hello 世界 🌍 Привет"' in content
1082
+
1083
+ def test_very_long_secret_value(self):
1084
+ """Test handling of very long secret values."""
1085
+ with tempfile.TemporaryDirectory() as tmpdir:
1086
+ env_path = os.path.join(tmpdir, ".env")
1087
+
1088
+ task_config = TaskConfig(
1089
+ {
1090
+ "options": {
1091
+ "env_path": env_path,
1092
+ "secrets": ["LONG_SECRET"],
1093
+ }
1094
+ }
1095
+ )
1096
+
1097
+ project_config = mock.Mock()
1098
+ project_config.repo_root = tmpdir
1099
+
1100
+ task = SecretsToEnv(
1101
+ project_config=project_config,
1102
+ task_config=task_config,
1103
+ org_config=None,
1104
+ )
1105
+
1106
+ mock_provider = mock.Mock()
1107
+ mock_provider.provider_type = "local"
1108
+ long_value = "x" * 10000 # 10k characters
1109
+ mock_provider.get_credentials.return_value = long_value
1110
+ task.provider = mock_provider
1111
+
1112
+ task()
1113
+
1114
+ # Verify file contents
1115
+ with open(env_path, "r") as f:
1116
+ content = f.read()
1117
+
1118
+ assert f'LONG_SECRET="{long_value}"' in content