cumulusci-plus 5.0.24__py3-none-any.whl → 5.0.26__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 cumulusci-plus might be problematic. Click here for more details.

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