|
13 | 13 | using Azure.DataApiBuilder.Config.Converters; |
14 | 14 | using Azure.DataApiBuilder.Config.ObjectModel; |
15 | 15 | using Azure.DataApiBuilder.Service.Exceptions; |
| 16 | +using Microsoft.Data.SqlClient; |
16 | 17 | using Microsoft.VisualStudio.TestTools.UnitTesting; |
17 | 18 |
|
18 | 19 | namespace Azure.DataApiBuilder.Service.Tests.UnitTests |
@@ -240,6 +241,7 @@ public void CheckCommentParsingInConfigFile() |
240 | 241 | /// but have the effect of default values when deserialized. |
241 | 242 | /// It starts with a minimal config and incrementally |
242 | 243 | /// adds the optional subproperties. At each step, tests for valid deserialization. |
| 244 | + /// </summary> |
243 | 245 | [TestMethod] |
244 | 246 | public void TestNullableOptionalProps() |
245 | 247 | { |
@@ -431,7 +433,7 @@ public static string GetModifiedJsonString(string[] reps, string enumString) |
431 | 433 | ""host"": { |
432 | 434 | ""mode"": ""development"", |
433 | 435 | ""cors"": { |
434 | | - ""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @""" ], |
| 436 | + ""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @"""], |
435 | 437 | ""allow-credentials"": true |
436 | 438 | }, |
437 | 439 | ""authentication"": { |
@@ -671,5 +673,179 @@ private static bool TryParseAndAssertOnDefaults(string json, out RuntimeConfig p |
671 | 673 | #endregion Helper Functions |
672 | 674 |
|
673 | 675 | record StubJsonType(string Foo); |
| 676 | + |
| 677 | + /// <summary> |
| 678 | + /// Test to verify Azure Key Vault variable replacement from local .akv file. |
| 679 | + /// </summary> |
| 680 | + [TestMethod] |
| 681 | + public void TestAkvVariableReplacementFromLocalFile() |
| 682 | + { |
| 683 | + // Arrange: create a temporary .akv secrets file |
| 684 | + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); |
| 685 | + string secretConnectionString = "Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"; |
| 686 | + File.WriteAllText(akvFilePath, $"DBCONN={secretConnectionString}\nAPI_KEY=abcd\n# Comment line should be ignored\n MALFORMEDLINE \n"); |
| 687 | + |
| 688 | + // Escape backslashes for JSON |
| 689 | + string escapedPath = akvFilePath.Replace("\\", "\\\\"); |
| 690 | + |
| 691 | + string jsonConfig = $$""" |
| 692 | + { |
| 693 | + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", |
| 694 | + "data-source": { |
| 695 | + "database-type": "mssql", |
| 696 | + "connection-string": "@akv('DBCONN')" |
| 697 | + }, |
| 698 | + "azure-key-vault": { |
| 699 | + "endpoint": "{{escapedPath}}" |
| 700 | + }, |
| 701 | + "entities": { } |
| 702 | + } |
| 703 | + """; |
| 704 | + |
| 705 | + try |
| 706 | + { |
| 707 | + // Act |
| 708 | + DeserializationVariableReplacementSettings replacementSettings = new( |
| 709 | + azureKeyVaultOptions: null, |
| 710 | + doReplaceEnvVar: false, |
| 711 | + doReplaceAkvVar: true); |
| 712 | + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); |
| 713 | + |
| 714 | + // Assert |
| 715 | + Assert.IsTrue(parsed, "Config should parse successfully with local AKV file replacement."); |
| 716 | + Assert.IsNotNull(config, "Config should not be null."); |
| 717 | + Assert.AreEqual(secretConnectionString, config.DataSource.ConnectionString, "Connection string should be replaced from AKV local file secret."); |
| 718 | + } |
| 719 | + finally |
| 720 | + { |
| 721 | + // Cleanup |
| 722 | + if (File.Exists(akvFilePath)) |
| 723 | + { |
| 724 | + File.Delete(akvFilePath); |
| 725 | + } |
| 726 | + } |
| 727 | + } |
| 728 | + |
| 729 | + /// <summary> |
| 730 | + /// Validates that when an AKV secret's value itself contains an @env('...') pattern, it is NOT further resolved |
| 731 | + /// because replacement only runs once per original JSON token. Demonstrates that nested env patterns inside |
| 732 | + /// AKV secret values are left intact. |
| 733 | + /// </summary> |
| 734 | + [TestMethod] |
| 735 | + public void TestAkvSecretValueContainingEnvPatternIsNotEnvExpanded() |
| 736 | + { |
| 737 | + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); |
| 738 | + // Valid MSSQL connection string which embeds an @env('env') pattern in the Database value. |
| 739 | + // This pattern should NOT be expanded because replacement only runs once on the original JSON token (@akv('DBCONN')). |
| 740 | + string secretValueWithEnvPattern = "Server=localhost;Database=@env('env');User Id=sa;Password=XXXX;"; |
| 741 | + File.WriteAllText(akvFilePath, $"DBCONN={secretValueWithEnvPattern}\n"); |
| 742 | + string escapedPath = akvFilePath.Replace("\\", "\\\\"); |
| 743 | + |
| 744 | + // Set env variable to prove it would be different if expansion occurred. |
| 745 | + Environment.SetEnvironmentVariable("env", "SHOULD_NOT_APPEAR"); |
| 746 | + |
| 747 | + string jsonConfig = $$""" |
| 748 | + { |
| 749 | + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", |
| 750 | + "data-source": { |
| 751 | + "database-type": "mssql", |
| 752 | + "connection-string": "@akv('DBCONN')" |
| 753 | + }, |
| 754 | + "azure-key-vault": { |
| 755 | + "endpoint": "{{escapedPath}}" |
| 756 | + }, |
| 757 | + "entities": { } |
| 758 | + } |
| 759 | + """; |
| 760 | + |
| 761 | + try |
| 762 | + { |
| 763 | + DeserializationVariableReplacementSettings replacementSettings = new( |
| 764 | + azureKeyVaultOptions: null, |
| 765 | + doReplaceEnvVar: true, |
| 766 | + doReplaceAkvVar: true); |
| 767 | + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); |
| 768 | + Assert.IsTrue(parsed, "Config should parse successfully."); |
| 769 | + Assert.IsNotNull(config); |
| 770 | + |
| 771 | + string actual = config.DataSource.ConnectionString; |
| 772 | + Assert.IsTrue(actual.Contains("@env('env')"), "Nested @env pattern inside AKV secret should remain unexpanded."); |
| 773 | + Assert.IsFalse(actual.Contains("SHOULD_NOT_APPEAR"), "Env var value should not be expanded inside AKV secret."); |
| 774 | + Assert.IsTrue(actual.Contains("Application Name="), "Application Name should be appended for MSSQL when env replacement is enabled."); |
| 775 | + |
| 776 | + var builderOriginal = new SqlConnectionStringBuilder(secretValueWithEnvPattern.Replace("Server=", "Data Source=").Replace("Database=", "Initial Catalog=")); |
| 777 | + var builderActual = new SqlConnectionStringBuilder(actual); |
| 778 | + Assert.AreEqual(builderOriginal["Data Source"], builderActual["Data Source"], "Server/Data Source should match."); |
| 779 | + Assert.AreEqual(builderOriginal["Initial Catalog"], builderActual["Initial Catalog"], "Database/Initial Catalog should match (with env pattern retained)."); |
| 780 | + Assert.AreEqual(builderOriginal["User ID"], builderActual["User ID"], "User Id should match."); |
| 781 | + Assert.AreEqual(builderOriginal["Password"], builderActual["Password"], "Password should match."); |
| 782 | + } |
| 783 | + finally |
| 784 | + { |
| 785 | + if (File.Exists(akvFilePath)) |
| 786 | + { |
| 787 | + File.Delete(akvFilePath); |
| 788 | + } |
| 789 | + |
| 790 | + Environment.SetEnvironmentVariable("env", null); |
| 791 | + } |
| 792 | + } |
| 793 | + |
| 794 | + /// <summary> |
| 795 | + /// Validates two-pass replacement where an env var resolves to an AKV pattern which then resolves to the secret value. |
| 796 | + /// connection-string = @env('env_variable'), env_variable value = @akv('DBCONN'), AKV secret DBCONN holds the final connection string. |
| 797 | + /// </summary> |
| 798 | + [TestMethod] |
| 799 | + public void TestEnvVariableResolvingToAkvPatternIsExpandedInSecondPass() |
| 800 | + { |
| 801 | + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); |
| 802 | + string finalSecretValue = "Server=localhost;Database=Test;User Id=sa;Password=XXXX;"; |
| 803 | + File.WriteAllText(akvFilePath, $"DBCONN={finalSecretValue}\n"); |
| 804 | + string escapedPath = akvFilePath.Replace("\\", "\\\\"); |
| 805 | + Environment.SetEnvironmentVariable("env_variable", "@akv('DBCONN')"); |
| 806 | + |
| 807 | + string jsonConfig = $$""" |
| 808 | + { |
| 809 | + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", |
| 810 | + "data-source": { |
| 811 | + "database-type": "mssql", |
| 812 | + "connection-string": "@env('env_variable')" |
| 813 | + }, |
| 814 | + "azure-key-vault": { |
| 815 | + "endpoint": "{{escapedPath}}" |
| 816 | + }, |
| 817 | + "entities": { } |
| 818 | + } |
| 819 | + """; |
| 820 | + |
| 821 | + try |
| 822 | + { |
| 823 | + DeserializationVariableReplacementSettings replacementSettings = new( |
| 824 | + azureKeyVaultOptions: null, |
| 825 | + doReplaceEnvVar: true, |
| 826 | + doReplaceAkvVar: true); |
| 827 | + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); |
| 828 | + Assert.IsTrue(parsed, "Config should parse successfully."); |
| 829 | + Assert.IsNotNull(config); |
| 830 | + |
| 831 | + string expected = RuntimeConfigLoader.GetConnectionStringWithApplicationName(finalSecretValue); |
| 832 | + var builderExpected = new SqlConnectionStringBuilder(expected); |
| 833 | + var builderActual = new SqlConnectionStringBuilder(config.DataSource.ConnectionString); |
| 834 | + Assert.AreEqual(builderExpected["Data Source"], builderActual["Data Source"], "Data Source should match."); |
| 835 | + Assert.AreEqual(builderExpected["Initial Catalog"], builderActual["Initial Catalog"], "Initial Catalog should match."); |
| 836 | + Assert.AreEqual(builderExpected["User ID"], builderActual["User ID"], "User ID should match."); |
| 837 | + Assert.AreEqual(builderExpected["Password"], builderActual["Password"], "Password should match."); |
| 838 | + Assert.IsTrue(builderActual.ApplicationName?.Contains("dab_"), "Application Name should be appended including product identifier."); |
| 839 | + } |
| 840 | + finally |
| 841 | + { |
| 842 | + if (File.Exists(akvFilePath)) |
| 843 | + { |
| 844 | + File.Delete(akvFilePath); |
| 845 | + } |
| 846 | + |
| 847 | + Environment.SetEnvironmentVariable("env_variable", null); |
| 848 | + } |
| 849 | + } |
674 | 850 | } |
675 | 851 | } |
0 commit comments