cumulusci-plus 5.0.35__py3-none-any.whl → 5.0.45__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 (39) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/cci.py +3 -2
  3. cumulusci/cli/task.py +9 -10
  4. cumulusci/cli/tests/test_org.py +5 -0
  5. cumulusci/cli/tests/test_task.py +34 -0
  6. cumulusci/core/config/__init__.py +1 -0
  7. cumulusci/core/config/org_config.py +2 -1
  8. cumulusci/core/config/project_config.py +12 -0
  9. cumulusci/core/config/scratch_org_config.py +12 -0
  10. cumulusci/core/config/sfdx_org_config.py +4 -1
  11. cumulusci/core/config/tests/test_config.py +1 -0
  12. cumulusci/core/dependencies/base.py +4 -0
  13. cumulusci/cumulusci.yml +18 -1
  14. cumulusci/schema/cumulusci.jsonschema.json +5 -0
  15. cumulusci/tasks/apex/testrunner.py +7 -4
  16. cumulusci/tasks/bulkdata/tests/test_select_utils.py +20 -0
  17. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  18. cumulusci/tasks/metadata_etl/applications.py +256 -0
  19. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  20. cumulusci/tasks/salesforce/insert_record.py +18 -19
  21. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  22. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  23. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +523 -8
  24. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  25. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  26. cumulusci/tasks/salesforce/update_external_credential.py +89 -4
  27. cumulusci/tasks/salesforce/update_record.py +217 -0
  28. cumulusci/tasks/sfdmu/sfdmu.py +14 -1
  29. cumulusci/tasks/utility/credentialManager.py +58 -12
  30. cumulusci/tasks/utility/secretsToEnv.py +42 -11
  31. cumulusci/tasks/utility/tests/test_credentialManager.py +586 -0
  32. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1240 -62
  33. cumulusci/utils/yaml/cumulusci_yml.py +1 -0
  34. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/METADATA +5 -7
  35. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/RECORD +39 -33
  36. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/WHEEL +1 -1
  37. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/entry_points.txt +0 -0
  38. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/licenses/AUTHORS.rst +0 -0
  39. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/licenses/LICENSE +0 -0
@@ -17,7 +17,7 @@ class TestSecretsToEnvOptions:
17
17
 
18
18
  def test_default_options(self):
19
19
  """Test initialization with default options."""
20
- task_config = TaskConfig({"options": {}})
20
+ task_config = TaskConfig({"options": {"secrets": ["TEST_SECRET"]}})
21
21
  with tempfile.TemporaryDirectory() as tmpdir:
22
22
  env_path = os.path.join(tmpdir, ".env")
23
23
  task_config.options["env_path"] = env_path
@@ -31,7 +31,7 @@ class TestSecretsToEnvOptions:
31
31
  assert task.parsed_options.env_path == Path(env_path)
32
32
  assert task.parsed_options.secrets_provider is None
33
33
  assert task.parsed_options.provider_options == {}
34
- assert task.parsed_options.secrets == []
34
+ assert task.parsed_options.secrets == ["TEST_SECRET"]
35
35
 
36
36
  def test_custom_options(self):
37
37
  """Test initialization with custom options."""
@@ -90,6 +90,7 @@ class TestSecretsToEnvInitialization:
90
90
  "options": {
91
91
  "env_path": ".env",
92
92
  "secrets_provider": "local",
93
+ "secrets": ["TEST_SECRET"],
93
94
  }
94
95
  }
95
96
  )
@@ -112,7 +113,9 @@ class TestSecretsToEnvInitialization:
112
113
  with open(env_path, "w") as f:
113
114
  f.write('EXISTING_KEY="existing_value"\n')
114
115
 
115
- task_config = TaskConfig({"options": {"env_path": env_path}})
116
+ task_config = TaskConfig(
117
+ {"options": {"env_path": env_path, "secrets": ["TEST_SECRET"]}}
118
+ )
116
119
 
117
120
  task = SecretsToEnv(
118
121
  project_config=mock.Mock(),
@@ -202,7 +205,9 @@ class TestSecretsToEnvGetCredential:
202
205
 
203
206
  def test_get_credential_success(self):
204
207
  """Test _get_credential successfully retrieves credential."""
205
- task_config = TaskConfig({"options": {"env_path": ".env"}})
208
+ task_config = TaskConfig(
209
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
210
+ )
206
211
 
207
212
  task = SecretsToEnv(
208
213
  project_config=mock.Mock(),
@@ -215,11 +220,10 @@ class TestSecretsToEnvGetCredential:
215
220
  mock_provider.get_credentials.return_value = "secret_value_123"
216
221
  task.provider = mock_provider
217
222
 
218
- safe_value, original_value = task._get_credential(
223
+ original_value = task._get_credential(
219
224
  "API_KEY", "api_key", secret_name="my-secret"
220
225
  )
221
226
 
222
- assert safe_value == "secret_value_123"
223
227
  assert original_value == "secret_value_123"
224
228
  mock_provider.get_credentials.assert_called_once_with(
225
229
  "API_KEY", {"value": "api_key", "secret_name": "my-secret"}
@@ -227,7 +231,9 @@ class TestSecretsToEnvGetCredential:
227
231
 
228
232
  def test_get_credential_escapes_quotes(self):
229
233
  """Test _get_credential escapes double quotes."""
230
- task_config = TaskConfig({"options": {"env_path": ".env"}})
234
+ task_config = TaskConfig(
235
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
236
+ )
231
237
 
232
238
  task = SecretsToEnv(
233
239
  project_config=mock.Mock(),
@@ -240,13 +246,15 @@ class TestSecretsToEnvGetCredential:
240
246
  mock_provider.get_credentials.return_value = 'value_with_"quotes"'
241
247
  task.provider = mock_provider
242
248
 
243
- safe_value, _ = task._get_credential("API_KEY", "api_key")
249
+ original_value = task._get_credential("API_KEY", "api_key")
244
250
 
245
- assert safe_value == 'value_with_\\"quotes\\"'
251
+ assert original_value == 'value_with_"quotes"'
246
252
 
247
253
  def test_get_credential_escapes_newlines(self):
248
254
  """Test _get_credential escapes newlines."""
249
- task_config = TaskConfig({"options": {"env_path": ".env"}})
255
+ task_config = TaskConfig(
256
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
257
+ )
250
258
 
251
259
  task = SecretsToEnv(
252
260
  project_config=mock.Mock(),
@@ -259,13 +267,15 @@ class TestSecretsToEnvGetCredential:
259
267
  mock_provider.get_credentials.return_value = "line1\nline2\nline3"
260
268
  task.provider = mock_provider
261
269
 
262
- safe_value, _ = task._get_credential("API_KEY", "api_key")
270
+ original_value = task._get_credential("API_KEY", "api_key")
263
271
 
264
- assert safe_value == "line1\\nline2\\nline3"
272
+ assert original_value == "line1\nline2\nline3"
265
273
 
266
274
  def test_get_credential_handles_both_quotes_and_newlines(self):
267
275
  """Test _get_credential handles both quotes and newlines."""
268
- task_config = TaskConfig({"options": {"env_path": ".env"}})
276
+ task_config = TaskConfig(
277
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
278
+ )
269
279
 
270
280
  task = SecretsToEnv(
271
281
  project_config=mock.Mock(),
@@ -278,13 +288,15 @@ class TestSecretsToEnvGetCredential:
278
288
  mock_provider.get_credentials.return_value = 'line1 "quoted"\nline2'
279
289
  task.provider = mock_provider
280
290
 
281
- safe_value, _ = task._get_credential("API_KEY", "api_key")
291
+ original_value = task._get_credential("API_KEY", "api_key")
282
292
 
283
- assert safe_value == 'line1 \\"quoted\\"\\nline2'
293
+ assert original_value == 'line1 "quoted"\nline2'
284
294
 
285
295
  def test_get_credential_with_none_value_raises_error(self):
286
296
  """Test _get_credential raises error when provider returns None."""
287
- task_config = TaskConfig({"options": {"env_path": ".env"}})
297
+ task_config = TaskConfig(
298
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
299
+ )
288
300
 
289
301
  task = SecretsToEnv(
290
302
  project_config=mock.Mock(),
@@ -304,7 +316,9 @@ class TestSecretsToEnvGetCredential:
304
316
 
305
317
  def test_get_credential_uses_env_key_parameter(self):
306
318
  """Test _get_credential uses custom env_key when provided."""
307
- task_config = TaskConfig({"options": {"env_path": ".env"}})
319
+ task_config = TaskConfig(
320
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
321
+ )
308
322
 
309
323
  task = SecretsToEnv(
310
324
  project_config=mock.Mock(),
@@ -317,18 +331,20 @@ class TestSecretsToEnvGetCredential:
317
331
  mock_provider.get_credentials.return_value = "secret_value"
318
332
  task.provider = mock_provider
319
333
 
320
- safe_value, _ = task._get_credential(
334
+ original_value = task._get_credential(
321
335
  "CREDENTIAL_KEY", "value", env_key="CUSTOM_ENV_KEY"
322
336
  )
323
337
 
324
- assert safe_value == "secret_value"
338
+ assert original_value == "secret_value"
325
339
 
326
340
  def test_get_credential_logs_masked_value(self, caplog):
327
341
  """Test _get_credential logs masked value."""
328
342
  import logging
329
343
 
330
344
  caplog.set_level(logging.INFO)
331
- task_config = TaskConfig({"options": {"env_path": ".env"}})
345
+ task_config = TaskConfig(
346
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
347
+ )
332
348
 
333
349
  task = SecretsToEnv(
334
350
  project_config=mock.Mock(),
@@ -352,7 +368,9 @@ class TestSecretsToEnvGetAllCredentials:
352
368
 
353
369
  def test_get_all_credentials_success(self):
354
370
  """Test _get_all_credentials successfully retrieves all credentials."""
355
- task_config = TaskConfig({"options": {"env_path": ".env"}})
371
+ task_config = TaskConfig(
372
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
373
+ )
356
374
 
357
375
  task = SecretsToEnv(
358
376
  project_config=mock.Mock(),
@@ -382,7 +400,9 @@ class TestSecretsToEnvGetAllCredentials:
382
400
 
383
401
  def test_get_all_credentials_escapes_quotes(self):
384
402
  """Test _get_all_credentials escapes quotes in all values."""
385
- task_config = TaskConfig({"options": {"env_path": ".env"}})
403
+ task_config = TaskConfig(
404
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
405
+ )
386
406
 
387
407
  task = SecretsToEnv(
388
408
  project_config=mock.Mock(),
@@ -400,12 +420,14 @@ class TestSecretsToEnvGetAllCredentials:
400
420
 
401
421
  result = task._get_all_credentials("*")
402
422
 
403
- assert result["KEY1"] == 'value_with_\\"quotes\\"'
423
+ assert result["KEY1"] == 'value_with_"quotes"'
404
424
  assert result["KEY2"] == "normal_value"
405
425
 
406
426
  def test_get_all_credentials_escapes_newlines(self):
407
427
  """Test _get_all_credentials escapes newlines in all values."""
408
- task_config = TaskConfig({"options": {"env_path": ".env"}})
428
+ task_config = TaskConfig(
429
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
430
+ )
409
431
 
410
432
  task = SecretsToEnv(
411
433
  project_config=mock.Mock(),
@@ -423,12 +445,14 @@ class TestSecretsToEnvGetAllCredentials:
423
445
 
424
446
  result = task._get_all_credentials("*")
425
447
 
426
- assert result["KEY1"] == "line1\\nline2"
448
+ assert result["KEY1"] == "line1\nline2"
427
449
  assert result["KEY2"] == "single_line"
428
450
 
429
451
  def test_get_all_credentials_with_none_value_raises_error(self):
430
452
  """Test _get_all_credentials raises error when provider returns None."""
431
- task_config = TaskConfig({"options": {"env_path": ".env"}})
453
+ task_config = TaskConfig(
454
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
455
+ )
432
456
 
433
457
  task = SecretsToEnv(
434
458
  project_config=mock.Mock(),
@@ -453,7 +477,9 @@ class TestSecretsToEnvGetAllCredentials:
453
477
  import logging
454
478
 
455
479
  caplog.set_level(logging.INFO)
456
- task_config = TaskConfig({"options": {"env_path": ".env"}})
480
+ task_config = TaskConfig(
481
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
482
+ )
457
483
 
458
484
  task = SecretsToEnv(
459
485
  project_config=mock.Mock(),
@@ -940,23 +966,187 @@ class TestSecretsToEnvIntegration:
940
966
  assert 'API_TOKEN="ado_token_value"' in content
941
967
 
942
968
 
943
- class TestSecretsToEnvEdgeCases:
944
- """Test edge cases and error conditions."""
969
+ class TestSecretsToEnvReturnValues:
970
+ """Test cases for return_values from task execution."""
945
971
 
946
- def test_empty_secrets_list_creates_empty_env_file(self):
947
- """Test that empty secrets list still creates/updates env file."""
972
+ def test_return_values_contains_env_values(self):
973
+ """Test that return_values contains env_values."""
974
+ with tempfile.TemporaryDirectory() as tmpdir:
975
+ env_path = os.path.join(tmpdir, ".env")
976
+
977
+ task_config = TaskConfig(
978
+ {
979
+ "options": {
980
+ "env_path": env_path,
981
+ "secrets": ["API_KEY"],
982
+ }
983
+ }
984
+ )
985
+
986
+ project_config = mock.Mock()
987
+ project_config.repo_root = tmpdir
988
+
989
+ task = SecretsToEnv(
990
+ project_config=project_config,
991
+ task_config=task_config,
992
+ org_config=None,
993
+ )
994
+
995
+ mock_provider = mock.Mock()
996
+ mock_provider.provider_type = "local"
997
+ mock_provider.get_credentials.return_value = "secret_value"
998
+ task.provider = mock_provider
999
+
1000
+ task()
1001
+
1002
+ # Verify return_values contains env_values
1003
+ assert "env_values" in task.return_values
1004
+ assert "API_KEY" in task.return_values["env_values"]
1005
+ assert task.return_values["env_values"]["API_KEY"] == "secret_value"
1006
+
1007
+ def test_return_values_contains_safe_env_values(self):
1008
+ """Test that return_values contains safe_env_values with escaped values."""
1009
+ with tempfile.TemporaryDirectory() as tmpdir:
1010
+ env_path = os.path.join(tmpdir, ".env")
1011
+
1012
+ task_config = TaskConfig(
1013
+ {
1014
+ "options": {
1015
+ "env_path": env_path,
1016
+ "secrets": ["QUOTE_SECRET"],
1017
+ }
1018
+ }
1019
+ )
1020
+
1021
+ project_config = mock.Mock()
1022
+ project_config.repo_root = tmpdir
1023
+
1024
+ task = SecretsToEnv(
1025
+ project_config=project_config,
1026
+ task_config=task_config,
1027
+ org_config=None,
1028
+ )
1029
+
1030
+ mock_provider = mock.Mock()
1031
+ mock_provider.provider_type = "local"
1032
+ mock_provider.get_credentials.return_value = 'value with "quotes"'
1033
+ task.provider = mock_provider
1034
+
1035
+ task()
1036
+
1037
+ # Verify return_values contains safe_env_values
1038
+ assert "safe_env_values" in task.return_values
1039
+ assert "QUOTE_SECRET" in task.return_values["safe_env_values"]
1040
+ # Verify quotes are escaped in safe_env_values
1041
+ assert (
1042
+ task.return_values["safe_env_values"]["QUOTE_SECRET"]
1043
+ == 'value with \\"quotes\\"'
1044
+ )
1045
+
1046
+ def test_return_values_safe_escapes_all_special_chars(self):
1047
+ """Test that safe_env_values properly escapes all special characters."""
1048
+ with tempfile.TemporaryDirectory() as tmpdir:
1049
+ env_path = os.path.join(tmpdir, ".env")
1050
+
1051
+ task_config = TaskConfig(
1052
+ {
1053
+ "options": {
1054
+ "env_path": env_path,
1055
+ "secrets": ["COMPLEX_SECRET"],
1056
+ }
1057
+ }
1058
+ )
1059
+
1060
+ project_config = mock.Mock()
1061
+ project_config.repo_root = tmpdir
1062
+
1063
+ task = SecretsToEnv(
1064
+ project_config=project_config,
1065
+ task_config=task_config,
1066
+ org_config=None,
1067
+ )
1068
+
1069
+ mock_provider = mock.Mock()
1070
+ mock_provider.provider_type = "local"
1071
+ mock_provider.get_credentials.return_value = "path\\file\nline\ttab\rreturn"
1072
+ task.provider = mock_provider
1073
+
1074
+ task()
1075
+
1076
+ # Verify safe_env_values has escaped characters
1077
+ safe_value = task.return_values["safe_env_values"]["COMPLEX_SECRET"]
1078
+ assert "\\\\" in safe_value # Escaped backslash
1079
+ assert "\\n" in safe_value # Escaped newline
1080
+ assert "\\t" in safe_value # Escaped tab
1081
+ assert "\\r" in safe_value # Escaped carriage return
1082
+
1083
+ def test_return_values_with_multiple_secrets(self):
1084
+ """Test return_values with multiple secrets."""
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": ["KEY1", "KEY2", "KEY3"],
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
+ mock_provider.get_credentials.side_effect = [
1109
+ "value1",
1110
+ 'value with "quotes"',
1111
+ "value with\nnewlines",
1112
+ ]
1113
+ task.provider = mock_provider
1114
+
1115
+ task()
1116
+
1117
+ # Verify all keys are in both env_values and safe_env_values
1118
+ assert len(task.return_values["env_values"]) == 3
1119
+ assert len(task.return_values["safe_env_values"]) == 3
1120
+
1121
+ # Verify original values in env_values
1122
+ assert task.return_values["env_values"]["KEY1"] == "value1"
1123
+ assert task.return_values["env_values"]["KEY2"] == 'value with "quotes"'
1124
+ assert task.return_values["env_values"]["KEY3"] == "value with\nnewlines"
1125
+
1126
+ # Verify escaped values in safe_env_values
1127
+ assert task.return_values["safe_env_values"]["KEY1"] == "value1"
1128
+ assert (
1129
+ task.return_values["safe_env_values"]["KEY2"]
1130
+ == 'value with \\"quotes\\"'
1131
+ )
1132
+ assert (
1133
+ task.return_values["safe_env_values"]["KEY3"] == "value with\\nnewlines"
1134
+ )
1135
+
1136
+ def test_return_values_preserves_existing_env_values(self):
1137
+ """Test that return_values includes existing env values."""
948
1138
  with tempfile.TemporaryDirectory() as tmpdir:
949
1139
  env_path = os.path.join(tmpdir, ".env")
950
1140
 
951
1141
  # Create existing .env file
952
1142
  with open(env_path, "w") as f:
953
- f.write('EXISTING="value"\n')
1143
+ f.write('EXISTING_KEY="existing_value"\n')
954
1144
 
955
1145
  task_config = TaskConfig(
956
1146
  {
957
1147
  "options": {
958
1148
  "env_path": env_path,
959
- "secrets": [],
1149
+ "secrets": ["NEW_KEY"],
960
1150
  }
961
1151
  }
962
1152
  )
@@ -970,20 +1160,25 @@ class TestSecretsToEnvEdgeCases:
970
1160
  org_config=None,
971
1161
  )
972
1162
 
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 = {}
1163
+ mock_provider = mock.Mock()
1164
+ mock_provider.provider_type = "local"
1165
+ mock_provider.get_credentials.return_value = "new_value"
1166
+ task.provider = mock_provider
976
1167
 
977
1168
  task()
978
1169
 
979
- # Verify existing content is preserved
980
- with open(env_path, "r") as f:
981
- content = f.read()
1170
+ # Verify return_values contains both existing and new values
1171
+ assert "EXISTING_KEY" in task.return_values["env_values"]
1172
+ assert "NEW_KEY" in task.return_values["env_values"]
1173
+ assert task.return_values["env_values"]["EXISTING_KEY"] == "existing_value"
1174
+ assert task.return_values["env_values"]["NEW_KEY"] == "new_value"
982
1175
 
983
- assert 'EXISTING="value"' in content
984
1176
 
985
- def test_special_characters_in_secret_values(self):
986
- """Test handling of special characters in secret values."""
1177
+ class TestSecretsToEnvFileFormat:
1178
+ """Test cases for .env file format validation."""
1179
+
1180
+ def test_env_file_has_correct_line_format(self):
1181
+ """Test that each line in .env file follows KEY="VALUE" format."""
987
1182
  with tempfile.TemporaryDirectory() as tmpdir:
988
1183
  env_path = os.path.join(tmpdir, ".env")
989
1184
 
@@ -991,7 +1186,7 @@ class TestSecretsToEnvEdgeCases:
991
1186
  {
992
1187
  "options": {
993
1188
  "env_path": env_path,
994
- "secrets": ["SPECIAL"],
1189
+ "secrets": ["KEY1", "KEY2", "KEY3"],
995
1190
  }
996
1191
  }
997
1192
  )
@@ -1007,18 +1202,24 @@ class TestSecretsToEnvEdgeCases:
1007
1202
 
1008
1203
  mock_provider = mock.Mock()
1009
1204
  mock_provider.provider_type = "local"
1010
- mock_provider.get_credentials.return_value = (
1011
- "value!@#$%^&*()[]{}|\\;':<>?,./~`"
1012
- )
1205
+ mock_provider.get_credentials.side_effect = ["value1", "value2", "value3"]
1013
1206
  task.provider = mock_provider
1014
1207
 
1015
1208
  task()
1016
1209
 
1017
- # Verify file can be read
1018
- assert os.path.exists(env_path)
1210
+ # Read raw file content
1211
+ with open(env_path, "r", encoding="utf-8") as f:
1212
+ lines = f.readlines()
1019
1213
 
1020
- def test_unicode_characters_in_secret_values(self):
1021
- """Test handling of unicode characters in secret values."""
1214
+ # Verify each line format
1215
+ import re
1216
+
1217
+ env_pattern = re.compile(r'^[A-Z_][A-Z0-9_]*=".*"\n$')
1218
+ for line in lines:
1219
+ assert env_pattern.match(line), f"Line does not match pattern: {line}"
1220
+
1221
+ def test_env_file_escapes_quotes_correctly(self):
1222
+ """Test that quotes in values are escaped correctly in file."""
1022
1223
  with tempfile.TemporaryDirectory() as tmpdir:
1023
1224
  env_path = os.path.join(tmpdir, ".env")
1024
1225
 
@@ -1026,7 +1227,7 @@ class TestSecretsToEnvEdgeCases:
1026
1227
  {
1027
1228
  "options": {
1028
1229
  "env_path": env_path,
1029
- "secrets": ["UNICODE"],
1230
+ "secrets": ["QUOTE_KEY"],
1030
1231
  }
1031
1232
  }
1032
1233
  )
@@ -1042,19 +1243,22 @@ class TestSecretsToEnvEdgeCases:
1042
1243
 
1043
1244
  mock_provider = mock.Mock()
1044
1245
  mock_provider.provider_type = "local"
1045
- mock_provider.get_credentials.return_value = "Hello 世界 🌍 Привет"
1246
+ mock_provider.get_credentials.return_value = 'She said "Hello"'
1046
1247
  task.provider = mock_provider
1047
1248
 
1048
1249
  task()
1049
1250
 
1050
- # Verify file contents
1251
+ # Read raw file content
1051
1252
  with open(env_path, "r", encoding="utf-8") as f:
1052
1253
  content = f.read()
1053
1254
 
1054
- assert 'UNICODE="Hello 世界 🌍 Привет"' in content
1255
+ # Verify quotes are escaped
1256
+ assert 'QUOTE_KEY="She said \\"Hello\\""' in content
1257
+ # Verify no unescaped quotes in the value
1258
+ assert 'She said "Hello"' not in content.split("=")[1]
1055
1259
 
1056
- def test_very_long_secret_value(self):
1057
- """Test handling of very long secret values."""
1260
+ def test_env_file_escapes_newlines_correctly(self):
1261
+ """Test that newlines in values are escaped correctly in file."""
1058
1262
  with tempfile.TemporaryDirectory() as tmpdir:
1059
1263
  env_path = os.path.join(tmpdir, ".env")
1060
1264
 
@@ -1062,7 +1266,7 @@ class TestSecretsToEnvEdgeCases:
1062
1266
  {
1063
1267
  "options": {
1064
1268
  "env_path": env_path,
1065
- "secrets": ["LONG_SECRET"],
1269
+ "secrets": ["MULTILINE_KEY"],
1066
1270
  }
1067
1271
  }
1068
1272
  )
@@ -1078,14 +1282,988 @@ class TestSecretsToEnvEdgeCases:
1078
1282
 
1079
1283
  mock_provider = mock.Mock()
1080
1284
  mock_provider.provider_type = "local"
1081
- long_value = "x" * 10000 # 10k characters
1082
- mock_provider.get_credentials.return_value = long_value
1285
+ mock_provider.get_credentials.return_value = "line1\nline2\nline3"
1083
1286
  task.provider = mock_provider
1084
1287
 
1085
1288
  task()
1086
1289
 
1087
- # Verify file contents
1088
- with open(env_path, "r") as f:
1290
+ # Read raw file content
1291
+ with open(env_path, "r", encoding="utf-8") as f:
1292
+ lines = f.readlines()
1293
+
1294
+ # Should be only one line (newlines are escaped)
1295
+ assert len(lines) == 1
1296
+ # Verify newlines are escaped as \n
1297
+ assert "\\n" in lines[0]
1298
+ assert lines[0] == 'MULTILINE_KEY="line1\\nline2\\nline3"\n'
1299
+
1300
+ def test_env_file_escapes_backslashes_correctly(self):
1301
+ """Test that backslashes in values are escaped correctly in file."""
1302
+ with tempfile.TemporaryDirectory() as tmpdir:
1303
+ env_path = os.path.join(tmpdir, ".env")
1304
+
1305
+ task_config = TaskConfig(
1306
+ {
1307
+ "options": {
1308
+ "env_path": env_path,
1309
+ "secrets": ["PATH_KEY"],
1310
+ }
1311
+ }
1312
+ )
1313
+
1314
+ project_config = mock.Mock()
1315
+ project_config.repo_root = tmpdir
1316
+
1317
+ task = SecretsToEnv(
1318
+ project_config=project_config,
1319
+ task_config=task_config,
1320
+ org_config=None,
1321
+ )
1322
+
1323
+ mock_provider = mock.Mock()
1324
+ mock_provider.provider_type = "local"
1325
+ mock_provider.get_credentials.return_value = "C:\\Users\\Admin"
1326
+ task.provider = mock_provider
1327
+
1328
+ task()
1329
+
1330
+ # Read raw file content
1331
+ with open(env_path, "r", encoding="utf-8") as f:
1089
1332
  content = f.read()
1090
1333
 
1091
- assert f'LONG_SECRET="{long_value}"' in content
1334
+ # Verify backslashes are escaped
1335
+ assert 'PATH_KEY="C:\\\\Users\\\\Admin"' in content
1336
+
1337
+ def test_env_file_loads_with_dotenv_values(self):
1338
+ """Test that .env file can be loaded with dotenv_values."""
1339
+ with tempfile.TemporaryDirectory() as tmpdir:
1340
+ env_path = os.path.join(tmpdir, ".env")
1341
+
1342
+ task_config = TaskConfig(
1343
+ {
1344
+ "options": {
1345
+ "env_path": env_path,
1346
+ "secrets": ["KEY1", "KEY2"],
1347
+ }
1348
+ }
1349
+ )
1350
+
1351
+ project_config = mock.Mock()
1352
+ project_config.repo_root = tmpdir
1353
+
1354
+ task = SecretsToEnv(
1355
+ project_config=project_config,
1356
+ task_config=task_config,
1357
+ org_config=None,
1358
+ )
1359
+
1360
+ mock_provider = mock.Mock()
1361
+ mock_provider.provider_type = "local"
1362
+ mock_provider.get_credentials.side_effect = [
1363
+ 'value with "quotes"',
1364
+ "value with\nnewlines",
1365
+ ]
1366
+ task.provider = mock_provider
1367
+
1368
+ task()
1369
+
1370
+ # Load with dotenv_values and verify no errors
1371
+ from dotenv import dotenv_values
1372
+
1373
+ loaded_values = dotenv_values(env_path)
1374
+ assert loaded_values["KEY1"] == 'value with "quotes"'
1375
+ assert loaded_values["KEY2"] == "value with\nnewlines"
1376
+
1377
+ def test_env_file_loads_with_load_dotenv(self):
1378
+ """Test that .env file can be loaded with load_dotenv."""
1379
+ with tempfile.TemporaryDirectory() as tmpdir:
1380
+ env_path = os.path.join(tmpdir, ".env")
1381
+
1382
+ task_config = TaskConfig(
1383
+ {
1384
+ "options": {
1385
+ "env_path": env_path,
1386
+ "secrets": ["TEST_KEY1", "TEST_KEY2"],
1387
+ }
1388
+ }
1389
+ )
1390
+
1391
+ project_config = mock.Mock()
1392
+ project_config.repo_root = tmpdir
1393
+
1394
+ task = SecretsToEnv(
1395
+ project_config=project_config,
1396
+ task_config=task_config,
1397
+ org_config=None,
1398
+ )
1399
+
1400
+ mock_provider = mock.Mock()
1401
+ mock_provider.provider_type = "local"
1402
+ mock_provider.get_credentials.side_effect = [
1403
+ "value with \"quotes\" and 'single quotes'",
1404
+ "value with\ttabs and\nnewlines",
1405
+ ]
1406
+ task.provider = mock_provider
1407
+
1408
+ task()
1409
+
1410
+ # Load with load_dotenv and verify no errors
1411
+ from dotenv import load_dotenv
1412
+
1413
+ # Clear any existing env vars
1414
+ for key in ["TEST_KEY1", "TEST_KEY2"]:
1415
+ os.environ.pop(key, None)
1416
+
1417
+ # Load the .env file
1418
+ load_dotenv(env_path)
1419
+
1420
+ # Verify values are loaded correctly
1421
+ assert (
1422
+ os.environ["TEST_KEY1"] == "value with \"quotes\" and 'single quotes'"
1423
+ )
1424
+ assert os.environ["TEST_KEY2"] == "value with\ttabs and\nnewlines"
1425
+
1426
+ # Cleanup
1427
+ del os.environ["TEST_KEY1"]
1428
+ del os.environ["TEST_KEY2"]
1429
+
1430
+ def test_env_file_with_all_escape_characters_loads_correctly(self):
1431
+ """Test that file with all escape characters can be loaded correctly."""
1432
+ with tempfile.TemporaryDirectory() as tmpdir:
1433
+ env_path = os.path.join(tmpdir, ".env")
1434
+
1435
+ task_config = TaskConfig(
1436
+ {
1437
+ "options": {
1438
+ "env_path": env_path,
1439
+ "secrets": ["ALL_ESCAPES"],
1440
+ }
1441
+ }
1442
+ )
1443
+
1444
+ project_config = mock.Mock()
1445
+ project_config.repo_root = tmpdir
1446
+
1447
+ task = SecretsToEnv(
1448
+ project_config=project_config,
1449
+ task_config=task_config,
1450
+ org_config=None,
1451
+ )
1452
+
1453
+ mock_provider = mock.Mock()
1454
+ mock_provider.provider_type = "local"
1455
+ # Test all escape characters
1456
+ test_value = "bs\\ dq\" sq' bell\a back\b ff\f nl\n cr\r tab\t vt\v"
1457
+ mock_provider.get_credentials.return_value = test_value
1458
+ task.provider = mock_provider
1459
+
1460
+ task()
1461
+
1462
+ # Verify file was written
1463
+ assert os.path.exists(env_path)
1464
+
1465
+ # Load with dotenv_values and verify
1466
+ from dotenv import dotenv_values
1467
+
1468
+ loaded_values = dotenv_values(env_path)
1469
+ assert "ALL_ESCAPES" in loaded_values
1470
+ assert loaded_values["ALL_ESCAPES"] == test_value
1471
+
1472
+ def test_env_file_utf8_encoding(self):
1473
+ """Test that .env file is written with UTF-8 encoding."""
1474
+ with tempfile.TemporaryDirectory() as tmpdir:
1475
+ env_path = os.path.join(tmpdir, ".env")
1476
+
1477
+ task_config = TaskConfig(
1478
+ {
1479
+ "options": {
1480
+ "env_path": env_path,
1481
+ "secrets": ["UNICODE_KEY"],
1482
+ }
1483
+ }
1484
+ )
1485
+
1486
+ project_config = mock.Mock()
1487
+ project_config.repo_root = tmpdir
1488
+
1489
+ task = SecretsToEnv(
1490
+ project_config=project_config,
1491
+ task_config=task_config,
1492
+ org_config=None,
1493
+ )
1494
+
1495
+ mock_provider = mock.Mock()
1496
+ mock_provider.provider_type = "local"
1497
+ mock_provider.get_credentials.return_value = "Hello 世界 🌍 مرحبا"
1498
+ task.provider = mock_provider
1499
+
1500
+ task()
1501
+
1502
+ # Read file with UTF-8 encoding
1503
+ with open(env_path, "r", encoding="utf-8") as f:
1504
+ content = f.read()
1505
+
1506
+ assert "Hello 世界 🌍 مرحبا" in content
1507
+
1508
+ # Load with dotenv_values and verify unicode is preserved
1509
+ from dotenv import dotenv_values
1510
+
1511
+ loaded_values = dotenv_values(env_path)
1512
+ assert loaded_values["UNICODE_KEY"] == "Hello 世界 🌍 مرحبا"
1513
+
1514
+
1515
+ class TestSecretsToEnvEdgeCases:
1516
+ """Test edge cases and error conditions."""
1517
+
1518
+ def test_empty_secrets_list_creates_empty_env_file(self):
1519
+ """Test that empty secrets list still creates/updates env file."""
1520
+ with tempfile.TemporaryDirectory() as tmpdir:
1521
+ env_path = os.path.join(tmpdir, ".env")
1522
+
1523
+ # Create existing .env file
1524
+ with open(env_path, "w") as f:
1525
+ f.write('EXISTING="value"\n')
1526
+
1527
+ task_config = TaskConfig(
1528
+ {
1529
+ "options": {
1530
+ "env_path": env_path,
1531
+ "secrets": [],
1532
+ }
1533
+ }
1534
+ )
1535
+
1536
+ project_config = mock.Mock()
1537
+ project_config.repo_root = tmpdir
1538
+
1539
+ task = SecretsToEnv(
1540
+ project_config=project_config,
1541
+ task_config=task_config,
1542
+ org_config=None,
1543
+ )
1544
+
1545
+ # When secrets is an empty list, _init_secrets doesn't set task.secrets
1546
+ # so we need to set it manually for this test
1547
+ task.secrets = {}
1548
+
1549
+ task()
1550
+
1551
+ # Verify existing content is preserved
1552
+ with open(env_path, "r") as f:
1553
+ content = f.read()
1554
+
1555
+ assert 'EXISTING="value"' in content
1556
+
1557
+ def test_special_characters_in_secret_values(self):
1558
+ """Test handling of special characters in secret values."""
1559
+ with tempfile.TemporaryDirectory() as tmpdir:
1560
+ env_path = os.path.join(tmpdir, ".env")
1561
+
1562
+ task_config = TaskConfig(
1563
+ {
1564
+ "options": {
1565
+ "env_path": env_path,
1566
+ "secrets": ["SPECIAL"],
1567
+ }
1568
+ }
1569
+ )
1570
+
1571
+ project_config = mock.Mock()
1572
+ project_config.repo_root = tmpdir
1573
+
1574
+ task = SecretsToEnv(
1575
+ project_config=project_config,
1576
+ task_config=task_config,
1577
+ org_config=None,
1578
+ )
1579
+
1580
+ mock_provider = mock.Mock()
1581
+ mock_provider.provider_type = "local"
1582
+ mock_provider.get_credentials.return_value = (
1583
+ "value!@#$%^&*()[]{}|\\;':<>?,./~`"
1584
+ )
1585
+ task.provider = mock_provider
1586
+
1587
+ task()
1588
+
1589
+ # Verify file can be read
1590
+ assert os.path.exists(env_path)
1591
+
1592
+ def test_unicode_characters_in_secret_values(self):
1593
+ """Test handling of unicode characters in secret values."""
1594
+ with tempfile.TemporaryDirectory() as tmpdir:
1595
+ env_path = os.path.join(tmpdir, ".env")
1596
+
1597
+ task_config = TaskConfig(
1598
+ {
1599
+ "options": {
1600
+ "env_path": env_path,
1601
+ "secrets": ["UNICODE"],
1602
+ }
1603
+ }
1604
+ )
1605
+
1606
+ project_config = mock.Mock()
1607
+ project_config.repo_root = tmpdir
1608
+
1609
+ task = SecretsToEnv(
1610
+ project_config=project_config,
1611
+ task_config=task_config,
1612
+ org_config=None,
1613
+ )
1614
+
1615
+ mock_provider = mock.Mock()
1616
+ mock_provider.provider_type = "local"
1617
+ mock_provider.get_credentials.return_value = "Hello 世界 🌍 Привет"
1618
+ task.provider = mock_provider
1619
+
1620
+ task()
1621
+
1622
+ # Verify file contents
1623
+ with open(env_path, "r", encoding="utf-8") as f:
1624
+ content = f.read()
1625
+
1626
+ assert 'UNICODE="Hello 世界 🌍 Привет"' in content
1627
+
1628
+ def test_very_long_secret_value(self):
1629
+ """Test handling of very long secret values."""
1630
+ with tempfile.TemporaryDirectory() as tmpdir:
1631
+ env_path = os.path.join(tmpdir, ".env")
1632
+
1633
+ task_config = TaskConfig(
1634
+ {
1635
+ "options": {
1636
+ "env_path": env_path,
1637
+ "secrets": ["LONG_SECRET"],
1638
+ }
1639
+ }
1640
+ )
1641
+
1642
+ project_config = mock.Mock()
1643
+ project_config.repo_root = tmpdir
1644
+
1645
+ task = SecretsToEnv(
1646
+ project_config=project_config,
1647
+ task_config=task_config,
1648
+ org_config=None,
1649
+ )
1650
+
1651
+ mock_provider = mock.Mock()
1652
+ mock_provider.provider_type = "local"
1653
+ long_value = "x" * 10000 # 10k characters
1654
+ mock_provider.get_credentials.return_value = long_value
1655
+ task.provider = mock_provider
1656
+
1657
+ task()
1658
+
1659
+ # Verify file contents
1660
+ with open(env_path, "r") as f:
1661
+ content = f.read()
1662
+
1663
+ assert f'LONG_SECRET="{long_value}"' in content
1664
+
1665
+
1666
+ class TestSecretsToEnvEscapeValues:
1667
+ """Test cases for _escape_env_value method."""
1668
+
1669
+ def test_escape_backslash(self):
1670
+ """Test escaping backslash characters."""
1671
+ task_config = TaskConfig(
1672
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1673
+ )
1674
+
1675
+ task = SecretsToEnv(
1676
+ project_config=mock.Mock(),
1677
+ task_config=task_config,
1678
+ org_config=None,
1679
+ )
1680
+
1681
+ result = task._escape_env_value("path\\to\\file")
1682
+ assert result == "path\\\\to\\\\file"
1683
+
1684
+ def test_escape_double_quotes(self):
1685
+ """Test escaping double quote characters."""
1686
+ task_config = TaskConfig(
1687
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1688
+ )
1689
+
1690
+ task = SecretsToEnv(
1691
+ project_config=mock.Mock(),
1692
+ task_config=task_config,
1693
+ org_config=None,
1694
+ )
1695
+
1696
+ result = task._escape_env_value('value with "quotes"')
1697
+ assert result == 'value with \\"quotes\\"'
1698
+
1699
+ def test_escape_single_quotes(self):
1700
+ """Test escaping single quote characters."""
1701
+ task_config = TaskConfig(
1702
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1703
+ )
1704
+
1705
+ task = SecretsToEnv(
1706
+ project_config=mock.Mock(),
1707
+ task_config=task_config,
1708
+ org_config=None,
1709
+ )
1710
+
1711
+ result = task._escape_env_value("value with 'quotes'")
1712
+ assert result == "value with \\'quotes\\'"
1713
+
1714
+ def test_escape_newline(self):
1715
+ """Test escaping newline characters."""
1716
+ task_config = TaskConfig(
1717
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1718
+ )
1719
+
1720
+ task = SecretsToEnv(
1721
+ project_config=mock.Mock(),
1722
+ task_config=task_config,
1723
+ org_config=None,
1724
+ )
1725
+
1726
+ result = task._escape_env_value("line1\nline2")
1727
+ assert result == "line1\\nline2"
1728
+
1729
+ def test_escape_carriage_return(self):
1730
+ """Test escaping carriage return characters."""
1731
+ task_config = TaskConfig(
1732
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1733
+ )
1734
+
1735
+ task = SecretsToEnv(
1736
+ project_config=mock.Mock(),
1737
+ task_config=task_config,
1738
+ org_config=None,
1739
+ )
1740
+
1741
+ result = task._escape_env_value("line1\rline2")
1742
+ assert result == "line1\\rline2"
1743
+
1744
+ def test_escape_tab(self):
1745
+ """Test escaping tab characters."""
1746
+ task_config = TaskConfig(
1747
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1748
+ )
1749
+
1750
+ task = SecretsToEnv(
1751
+ project_config=mock.Mock(),
1752
+ task_config=task_config,
1753
+ org_config=None,
1754
+ )
1755
+
1756
+ result = task._escape_env_value("col1\tcol2")
1757
+ assert result == "col1\\tcol2"
1758
+
1759
+ def test_escape_bell(self):
1760
+ """Test escaping bell/alert characters."""
1761
+ task_config = TaskConfig(
1762
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1763
+ )
1764
+
1765
+ task = SecretsToEnv(
1766
+ project_config=mock.Mock(),
1767
+ task_config=task_config,
1768
+ org_config=None,
1769
+ )
1770
+
1771
+ result = task._escape_env_value("text\abell")
1772
+ assert result == "text\\abell"
1773
+
1774
+ def test_escape_backspace(self):
1775
+ """Test escaping backspace characters."""
1776
+ task_config = TaskConfig(
1777
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1778
+ )
1779
+
1780
+ task = SecretsToEnv(
1781
+ project_config=mock.Mock(),
1782
+ task_config=task_config,
1783
+ org_config=None,
1784
+ )
1785
+
1786
+ result = task._escape_env_value("text\bback")
1787
+ assert result == "text\\bback"
1788
+
1789
+ def test_escape_form_feed(self):
1790
+ """Test escaping form feed characters."""
1791
+ task_config = TaskConfig(
1792
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1793
+ )
1794
+
1795
+ task = SecretsToEnv(
1796
+ project_config=mock.Mock(),
1797
+ task_config=task_config,
1798
+ org_config=None,
1799
+ )
1800
+
1801
+ result = task._escape_env_value("text\fform")
1802
+ assert result == "text\\fform"
1803
+
1804
+ def test_escape_vertical_tab(self):
1805
+ """Test escaping vertical tab characters."""
1806
+ task_config = TaskConfig(
1807
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1808
+ )
1809
+
1810
+ task = SecretsToEnv(
1811
+ project_config=mock.Mock(),
1812
+ task_config=task_config,
1813
+ org_config=None,
1814
+ )
1815
+
1816
+ result = task._escape_env_value("text\vvertical")
1817
+ assert result == "text\\vvertical"
1818
+
1819
+ def test_escape_multiple_special_chars(self):
1820
+ """Test escaping multiple special characters together."""
1821
+ task_config = TaskConfig(
1822
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1823
+ )
1824
+
1825
+ task = SecretsToEnv(
1826
+ project_config=mock.Mock(),
1827
+ task_config=task_config,
1828
+ org_config=None,
1829
+ )
1830
+
1831
+ result = task._escape_env_value('path\\to\\"file"\nwith\ttabs')
1832
+ assert result == 'path\\\\to\\\\\\"file\\"\\nwith\\ttabs'
1833
+
1834
+ def test_escape_non_string_value(self):
1835
+ """Test that non-string values are returned unchanged."""
1836
+ task_config = TaskConfig(
1837
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1838
+ )
1839
+
1840
+ task = SecretsToEnv(
1841
+ project_config=mock.Mock(),
1842
+ task_config=task_config,
1843
+ org_config=None,
1844
+ )
1845
+
1846
+ # Test with None
1847
+ assert task._escape_env_value(None) is None
1848
+
1849
+ # Test with number
1850
+ assert task._escape_env_value(123) == 123
1851
+
1852
+ # Test with boolean
1853
+ assert task._escape_env_value(True) is True
1854
+
1855
+ def test_escape_empty_string(self):
1856
+ """Test escaping empty string."""
1857
+ task_config = TaskConfig(
1858
+ {"options": {"env_path": ".env", "secrets": ["TEST_SECRET"]}}
1859
+ )
1860
+
1861
+ task = SecretsToEnv(
1862
+ project_config=mock.Mock(),
1863
+ task_config=task_config,
1864
+ org_config=None,
1865
+ )
1866
+
1867
+ result = task._escape_env_value("")
1868
+ assert result == ""
1869
+
1870
+
1871
+ class TestSecretsToEnvRoundTrip:
1872
+ """Test cases for round-trip write and load of .env files."""
1873
+
1874
+ def test_roundtrip_with_quotes(self):
1875
+ """Test writing and loading .env file with quoted values."""
1876
+ with tempfile.TemporaryDirectory() as tmpdir:
1877
+ env_path = os.path.join(tmpdir, ".env")
1878
+
1879
+ task_config = TaskConfig(
1880
+ {
1881
+ "options": {
1882
+ "env_path": env_path,
1883
+ "secrets": ["QUOTE_SECRET"],
1884
+ }
1885
+ }
1886
+ )
1887
+
1888
+ project_config = mock.Mock()
1889
+ project_config.repo_root = tmpdir
1890
+
1891
+ task = SecretsToEnv(
1892
+ project_config=project_config,
1893
+ task_config=task_config,
1894
+ org_config=None,
1895
+ )
1896
+
1897
+ mock_provider = mock.Mock()
1898
+ mock_provider.provider_type = "local"
1899
+ mock_provider.get_credentials.return_value = 'value with "quotes"'
1900
+ task.provider = mock_provider
1901
+
1902
+ task()
1903
+
1904
+ # Load the file back and verify
1905
+ from dotenv import dotenv_values
1906
+
1907
+ loaded_values = dotenv_values(env_path)
1908
+ assert "QUOTE_SECRET" in loaded_values
1909
+ assert loaded_values["QUOTE_SECRET"] == 'value with "quotes"'
1910
+
1911
+ def test_roundtrip_with_newlines(self):
1912
+ """Test writing and loading .env file with newline values."""
1913
+ with tempfile.TemporaryDirectory() as tmpdir:
1914
+ env_path = os.path.join(tmpdir, ".env")
1915
+
1916
+ task_config = TaskConfig(
1917
+ {
1918
+ "options": {
1919
+ "env_path": env_path,
1920
+ "secrets": ["MULTILINE_SECRET"],
1921
+ }
1922
+ }
1923
+ )
1924
+
1925
+ project_config = mock.Mock()
1926
+ project_config.repo_root = tmpdir
1927
+
1928
+ task = SecretsToEnv(
1929
+ project_config=project_config,
1930
+ task_config=task_config,
1931
+ org_config=None,
1932
+ )
1933
+
1934
+ mock_provider = mock.Mock()
1935
+ mock_provider.provider_type = "local"
1936
+ mock_provider.get_credentials.return_value = "line1\nline2\nline3"
1937
+ task.provider = mock_provider
1938
+
1939
+ task()
1940
+
1941
+ # Load the file back and verify
1942
+ from dotenv import dotenv_values
1943
+
1944
+ loaded_values = dotenv_values(env_path)
1945
+ assert "MULTILINE_SECRET" in loaded_values
1946
+ assert loaded_values["MULTILINE_SECRET"] == "line1\nline2\nline3"
1947
+
1948
+ def test_roundtrip_with_backslashes(self):
1949
+ """Test writing and loading .env file with backslash values."""
1950
+ with tempfile.TemporaryDirectory() as tmpdir:
1951
+ env_path = os.path.join(tmpdir, ".env")
1952
+
1953
+ task_config = TaskConfig(
1954
+ {
1955
+ "options": {
1956
+ "env_path": env_path,
1957
+ "secrets": ["PATH_SECRET"],
1958
+ }
1959
+ }
1960
+ )
1961
+
1962
+ project_config = mock.Mock()
1963
+ project_config.repo_root = tmpdir
1964
+
1965
+ task = SecretsToEnv(
1966
+ project_config=project_config,
1967
+ task_config=task_config,
1968
+ org_config=None,
1969
+ )
1970
+
1971
+ mock_provider = mock.Mock()
1972
+ mock_provider.provider_type = "local"
1973
+ mock_provider.get_credentials.return_value = "C:\\Users\\Admin\\Documents"
1974
+ task.provider = mock_provider
1975
+
1976
+ task()
1977
+
1978
+ # Load the file back and verify
1979
+ from dotenv import dotenv_values
1980
+
1981
+ loaded_values = dotenv_values(env_path)
1982
+ assert "PATH_SECRET" in loaded_values
1983
+ assert loaded_values["PATH_SECRET"] == "C:\\Users\\Admin\\Documents"
1984
+
1985
+ def test_roundtrip_with_tabs(self):
1986
+ """Test writing and loading .env file with tab values."""
1987
+ with tempfile.TemporaryDirectory() as tmpdir:
1988
+ env_path = os.path.join(tmpdir, ".env")
1989
+
1990
+ task_config = TaskConfig(
1991
+ {
1992
+ "options": {
1993
+ "env_path": env_path,
1994
+ "secrets": ["TAB_SECRET"],
1995
+ }
1996
+ }
1997
+ )
1998
+
1999
+ project_config = mock.Mock()
2000
+ project_config.repo_root = tmpdir
2001
+
2002
+ task = SecretsToEnv(
2003
+ project_config=project_config,
2004
+ task_config=task_config,
2005
+ org_config=None,
2006
+ )
2007
+
2008
+ mock_provider = mock.Mock()
2009
+ mock_provider.provider_type = "local"
2010
+ mock_provider.get_credentials.return_value = "col1\tcol2\tcol3"
2011
+ task.provider = mock_provider
2012
+
2013
+ task()
2014
+
2015
+ # Load the file back and verify
2016
+ from dotenv import dotenv_values
2017
+
2018
+ loaded_values = dotenv_values(env_path)
2019
+ assert "TAB_SECRET" in loaded_values
2020
+ assert loaded_values["TAB_SECRET"] == "col1\tcol2\tcol3"
2021
+
2022
+ def test_roundtrip_with_mixed_special_chars(self):
2023
+ """Test writing and loading .env file with mixed special characters."""
2024
+ with tempfile.TemporaryDirectory() as tmpdir:
2025
+ env_path = os.path.join(tmpdir, ".env")
2026
+
2027
+ task_config = TaskConfig(
2028
+ {
2029
+ "options": {
2030
+ "env_path": env_path,
2031
+ "secrets": ["COMPLEX_SECRET"],
2032
+ }
2033
+ }
2034
+ )
2035
+
2036
+ project_config = mock.Mock()
2037
+ project_config.repo_root = tmpdir
2038
+
2039
+ task = SecretsToEnv(
2040
+ project_config=project_config,
2041
+ task_config=task_config,
2042
+ org_config=None,
2043
+ )
2044
+
2045
+ mock_provider = mock.Mock()
2046
+ mock_provider.provider_type = "local"
2047
+ complex_value = "path\\to\\\"file\"\nwith\ttabs\rand'quotes'"
2048
+ mock_provider.get_credentials.return_value = complex_value
2049
+ task.provider = mock_provider
2050
+
2051
+ task()
2052
+
2053
+ # Load the file back and verify
2054
+ from dotenv import dotenv_values
2055
+
2056
+ loaded_values = dotenv_values(env_path)
2057
+ assert "COMPLEX_SECRET" in loaded_values
2058
+ assert loaded_values["COMPLEX_SECRET"] == complex_value
2059
+
2060
+ def test_roundtrip_with_json_string(self):
2061
+ """Test writing and loading .env file with JSON string values."""
2062
+ import json
2063
+
2064
+ with tempfile.TemporaryDirectory() as tmpdir:
2065
+ env_path = os.path.join(tmpdir, ".env")
2066
+
2067
+ task_config = TaskConfig(
2068
+ {
2069
+ "options": {
2070
+ "env_path": env_path,
2071
+ "secrets": ["JSON_SECRET"],
2072
+ }
2073
+ }
2074
+ )
2075
+
2076
+ project_config = mock.Mock()
2077
+ project_config.repo_root = tmpdir
2078
+
2079
+ task = SecretsToEnv(
2080
+ project_config=project_config,
2081
+ task_config=task_config,
2082
+ org_config=None,
2083
+ )
2084
+
2085
+ mock_provider = mock.Mock()
2086
+ mock_provider.provider_type = "local"
2087
+ json_value = json.dumps({"key": "value", "nested": {"data": "test"}})
2088
+ mock_provider.get_credentials.return_value = json_value
2089
+ task.provider = mock_provider
2090
+
2091
+ task()
2092
+
2093
+ # Load the file back and verify
2094
+ from dotenv import dotenv_values
2095
+
2096
+ loaded_values = dotenv_values(env_path)
2097
+ assert "JSON_SECRET" in loaded_values
2098
+ assert loaded_values["JSON_SECRET"] == json_value
2099
+ # Verify JSON can be parsed
2100
+ assert json.loads(loaded_values["JSON_SECRET"]) == {
2101
+ "key": "value",
2102
+ "nested": {"data": "test"},
2103
+ }
2104
+
2105
+ def test_roundtrip_with_multiple_secrets(self):
2106
+ """Test writing and loading .env file with multiple secrets."""
2107
+ with tempfile.TemporaryDirectory() as tmpdir:
2108
+ env_path = os.path.join(tmpdir, ".env")
2109
+
2110
+ task_config = TaskConfig(
2111
+ {
2112
+ "options": {
2113
+ "env_path": env_path,
2114
+ "secrets": ["SECRET1", "SECRET2", "SECRET3"],
2115
+ }
2116
+ }
2117
+ )
2118
+
2119
+ project_config = mock.Mock()
2120
+ project_config.repo_root = tmpdir
2121
+
2122
+ task = SecretsToEnv(
2123
+ project_config=project_config,
2124
+ task_config=task_config,
2125
+ org_config=None,
2126
+ )
2127
+
2128
+ mock_provider = mock.Mock()
2129
+ mock_provider.provider_type = "local"
2130
+ mock_provider.get_credentials.side_effect = [
2131
+ 'value with "quotes"',
2132
+ "line1\nline2",
2133
+ "path\\to\\file",
2134
+ ]
2135
+ task.provider = mock_provider
2136
+
2137
+ task()
2138
+
2139
+ # Load the file back and verify
2140
+ from dotenv import dotenv_values
2141
+
2142
+ loaded_values = dotenv_values(env_path)
2143
+ assert loaded_values["SECRET1"] == 'value with "quotes"'
2144
+ assert loaded_values["SECRET2"] == "line1\nline2"
2145
+ assert loaded_values["SECRET3"] == "path\\to\\file"
2146
+
2147
+ def test_roundtrip_preserves_all_escape_chars(self):
2148
+ """Test that all escape characters are preserved in round-trip."""
2149
+ with tempfile.TemporaryDirectory() as tmpdir:
2150
+ env_path = os.path.join(tmpdir, ".env")
2151
+
2152
+ task_config = TaskConfig(
2153
+ {
2154
+ "options": {
2155
+ "env_path": env_path,
2156
+ "secrets": ["ALL_ESCAPES"],
2157
+ }
2158
+ }
2159
+ )
2160
+
2161
+ project_config = mock.Mock()
2162
+ project_config.repo_root = tmpdir
2163
+
2164
+ task = SecretsToEnv(
2165
+ project_config=project_config,
2166
+ task_config=task_config,
2167
+ org_config=None,
2168
+ )
2169
+
2170
+ mock_provider = mock.Mock()
2171
+ mock_provider.provider_type = "local"
2172
+ # Test all escape characters: \\ \' \" \a \b \f \n \r \t \v
2173
+ test_value = "backslash\\ quote\" single' bell\a back\b form\f new\n ret\r tab\t vert\v"
2174
+ mock_provider.get_credentials.return_value = test_value
2175
+ task.provider = mock_provider
2176
+
2177
+ task()
2178
+
2179
+ # Load the file back and verify
2180
+ from dotenv import dotenv_values
2181
+
2182
+ loaded_values = dotenv_values(env_path)
2183
+ assert "ALL_ESCAPES" in loaded_values
2184
+ assert loaded_values["ALL_ESCAPES"] == test_value
2185
+
2186
+ def test_roundtrip_with_unicode_and_escapes(self):
2187
+ """Test writing and loading .env file with unicode and escape characters."""
2188
+ with tempfile.TemporaryDirectory() as tmpdir:
2189
+ env_path = os.path.join(tmpdir, ".env")
2190
+
2191
+ task_config = TaskConfig(
2192
+ {
2193
+ "options": {
2194
+ "env_path": env_path,
2195
+ "secrets": ["UNICODE_ESCAPE"],
2196
+ }
2197
+ }
2198
+ )
2199
+
2200
+ project_config = mock.Mock()
2201
+ project_config.repo_root = tmpdir
2202
+
2203
+ task = SecretsToEnv(
2204
+ project_config=project_config,
2205
+ task_config=task_config,
2206
+ org_config=None,
2207
+ )
2208
+
2209
+ mock_provider = mock.Mock()
2210
+ mock_provider.provider_type = "local"
2211
+ unicode_value = 'Hello 世界\n🌍 "Привет"\tДруг'
2212
+ mock_provider.get_credentials.return_value = unicode_value
2213
+ task.provider = mock_provider
2214
+
2215
+ task()
2216
+
2217
+ # Load the file back and verify
2218
+ from dotenv import dotenv_values
2219
+
2220
+ loaded_values = dotenv_values(env_path)
2221
+ assert "UNICODE_ESCAPE" in loaded_values
2222
+ assert loaded_values["UNICODE_ESCAPE"] == unicode_value
2223
+
2224
+ def test_env_file_format_validation(self):
2225
+ """Test that the .env file format is valid and well-formed."""
2226
+ with tempfile.TemporaryDirectory() as tmpdir:
2227
+ env_path = os.path.join(tmpdir, ".env")
2228
+
2229
+ task_config = TaskConfig(
2230
+ {
2231
+ "options": {
2232
+ "env_path": env_path,
2233
+ "secrets": ["KEY1", "KEY2"],
2234
+ }
2235
+ }
2236
+ )
2237
+
2238
+ project_config = mock.Mock()
2239
+ project_config.repo_root = tmpdir
2240
+
2241
+ task = SecretsToEnv(
2242
+ project_config=project_config,
2243
+ task_config=task_config,
2244
+ org_config=None,
2245
+ )
2246
+
2247
+ mock_provider = mock.Mock()
2248
+ mock_provider.provider_type = "local"
2249
+ mock_provider.get_credentials.side_effect = ["value1", "value2"]
2250
+ task.provider = mock_provider
2251
+
2252
+ task()
2253
+
2254
+ # Read and validate file format
2255
+ with open(env_path, "r", encoding="utf-8") as f:
2256
+ lines = f.readlines()
2257
+
2258
+ # Each line should be in format: KEY="VALUE"\n
2259
+ assert len(lines) == 2
2260
+ assert lines[0] == 'KEY1="value1"\n'
2261
+ assert lines[1] == 'KEY2="value2"\n'
2262
+
2263
+ # Verify no errors when loading
2264
+ from dotenv import dotenv_values
2265
+
2266
+ loaded_values = dotenv_values(env_path)
2267
+ assert len(loaded_values) == 2
2268
+ assert loaded_values["KEY1"] == "value1"
2269
+ assert loaded_values["KEY2"] == "value2"