|
11 | 11 | using System.CommandLine.Builder; |
12 | 12 | using System.CommandLine.IO; |
13 | 13 | using System.CommandLine.Parsing; |
| 14 | +using System.Text.Json; |
| 15 | +using System.Text.Json.Nodes; |
14 | 16 | using Xunit; |
15 | 17 |
|
16 | 18 | namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; |
@@ -1824,4 +1826,152 @@ public void ValidateMutuallyExclusiveOptions_WithCompatibleOptions_ShouldReturnT |
1824 | 1826 | } |
1825 | 1827 |
|
1826 | 1828 | #endregion |
| 1829 | + |
| 1830 | + #region Generated Config Merge Preservation Tests |
| 1831 | + |
| 1832 | + /// <summary> |
| 1833 | + /// Verifies that the blueprint intermediate save pattern (merge into existing JsonObject) |
| 1834 | + /// preserves all pre-existing fields such as agentBlueprintClientSecret, botId, etc. |
| 1835 | + /// Regression test for bug where a new JsonObject replaced the existing config, |
| 1836 | + /// dropping fields not explicitly listed in the allowlist. |
| 1837 | + /// </summary> |
| 1838 | + [Fact] |
| 1839 | + public async Task BlueprintIntermediateSave_ShouldPreserveExistingGeneratedConfigFields() |
| 1840 | + { |
| 1841 | + // Arrange - simulate a generated config with fields set by other subcommands |
| 1842 | + var tempDir = Path.Combine(Path.GetTempPath(), $"a365test_{Guid.NewGuid():N}"); |
| 1843 | + Directory.CreateDirectory(tempDir); |
| 1844 | + var generatedConfigPath = Path.Combine(tempDir, "a365.generated.config.json"); |
| 1845 | + |
| 1846 | + try |
| 1847 | + { |
| 1848 | + var existingConfig = new JsonObject |
| 1849 | + { |
| 1850 | + ["managedIdentityPrincipalId"] = "msi-principal-id-123", |
| 1851 | + ["agentBlueprintId"] = "old-blueprint-id", |
| 1852 | + ["agentBlueprintObjectId"] = "old-object-id", |
| 1853 | + ["agentBlueprintServicePrincipalObjectId"] = "old-sp-id", |
| 1854 | + ["agentBlueprintClientSecret"] = "encrypted-secret-value", |
| 1855 | + ["agentBlueprintClientSecretProtected"] = true, |
| 1856 | + ["botId"] = "bot-id-456", |
| 1857 | + ["botMsaAppId"] = "bot-msa-app-id-789", |
| 1858 | + ["botMessagingEndpoint"] = "https://myapp.azurewebsites.net/api/messages", |
| 1859 | + ["completed"] = true, |
| 1860 | + ["completedAt"] = "2026-01-01T00:00:00Z", |
| 1861 | + ["resourceConsents"] = new JsonArray |
| 1862 | + { |
| 1863 | + new JsonObject { ["resourceName"] = "Microsoft Graph", ["consentGranted"] = true } |
| 1864 | + } |
| 1865 | + }; |
| 1866 | + |
| 1867 | + await File.WriteAllTextAsync(generatedConfigPath, existingConfig.ToJsonString( |
| 1868 | + new JsonSerializerOptions { WriteIndented = true })); |
| 1869 | + |
| 1870 | + // Act - simulate the merge pattern used in BlueprintSubcommand |
| 1871 | + var generatedConfig = JsonNode.Parse( |
| 1872 | + await File.ReadAllTextAsync(generatedConfigPath))?.AsObject() ?? new JsonObject(); |
| 1873 | + |
| 1874 | + var newBlueprintAppId = "new-blueprint-app-id"; |
| 1875 | + var newBlueprintObjectId = "new-object-id"; |
| 1876 | + var newServicePrincipalId = "new-sp-id"; |
| 1877 | + |
| 1878 | + // This is the exact pattern from the fix |
| 1879 | + generatedConfig["agentBlueprintId"] = newBlueprintAppId; |
| 1880 | + generatedConfig["agentBlueprintObjectId"] = newBlueprintObjectId; |
| 1881 | + generatedConfig["agentBlueprintServicePrincipalObjectId"] = newServicePrincipalId; |
| 1882 | + if (generatedConfig["resourceConsents"] == null) |
| 1883 | + { |
| 1884 | + generatedConfig["resourceConsents"] = new JsonArray(); |
| 1885 | + } |
| 1886 | + |
| 1887 | + await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString( |
| 1888 | + new JsonSerializerOptions { WriteIndented = true })); |
| 1889 | + |
| 1890 | + // Assert - read back and verify ALL fields are preserved |
| 1891 | + var savedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath))!.AsObject(); |
| 1892 | + |
| 1893 | + // Updated fields should have new values |
| 1894 | + savedConfig["agentBlueprintId"]!.GetValue<string>().Should().Be("new-blueprint-app-id"); |
| 1895 | + savedConfig["agentBlueprintObjectId"]!.GetValue<string>().Should().Be("new-object-id"); |
| 1896 | + savedConfig["agentBlueprintServicePrincipalObjectId"]!.GetValue<string>().Should().Be("new-sp-id"); |
| 1897 | + |
| 1898 | + // Pre-existing fields must be preserved (the bug would wipe these) |
| 1899 | + savedConfig["agentBlueprintClientSecret"]!.GetValue<string>().Should().Be("encrypted-secret-value"); |
| 1900 | + savedConfig["agentBlueprintClientSecretProtected"]!.GetValue<bool>().Should().BeTrue(); |
| 1901 | + savedConfig["botId"]!.GetValue<string>().Should().Be("bot-id-456"); |
| 1902 | + savedConfig["botMsaAppId"]!.GetValue<string>().Should().Be("bot-msa-app-id-789"); |
| 1903 | + savedConfig["botMessagingEndpoint"]!.GetValue<string>().Should().Be("https://myapp.azurewebsites.net/api/messages"); |
| 1904 | + savedConfig["managedIdentityPrincipalId"]!.GetValue<string>().Should().Be("msi-principal-id-123"); |
| 1905 | + savedConfig["completed"]!.GetValue<bool>().Should().BeTrue(); |
| 1906 | + savedConfig["completedAt"]!.GetValue<string>().Should().Be("2026-01-01T00:00:00Z"); |
| 1907 | + |
| 1908 | + // Resource consents should be preserved |
| 1909 | + savedConfig["resourceConsents"]!.AsArray().Should().HaveCount(1); |
| 1910 | + savedConfig["resourceConsents"]![0]!["resourceName"]!.GetValue<string>().Should().Be("Microsoft Graph"); |
| 1911 | + } |
| 1912 | + finally |
| 1913 | + { |
| 1914 | + // Cleanup temp directory |
| 1915 | + if (Directory.Exists(tempDir)) |
| 1916 | + { |
| 1917 | + Directory.Delete(tempDir, recursive: true); |
| 1918 | + } |
| 1919 | + } |
| 1920 | + } |
| 1921 | + |
| 1922 | + /// <summary> |
| 1923 | + /// Verifies that the merge pattern initializes resourceConsents when it does not exist. |
| 1924 | + /// </summary> |
| 1925 | + [Fact] |
| 1926 | + public async Task BlueprintIntermediateSave_ShouldInitializeResourceConsents_WhenNull() |
| 1927 | + { |
| 1928 | + // Arrange - config without resourceConsents |
| 1929 | + var tempDir = Path.Combine(Path.GetTempPath(), $"a365test_{Guid.NewGuid():N}"); |
| 1930 | + Directory.CreateDirectory(tempDir); |
| 1931 | + var generatedConfigPath = Path.Combine(tempDir, "a365.generated.config.json"); |
| 1932 | + |
| 1933 | + try |
| 1934 | + { |
| 1935 | + var existingConfig = new JsonObject |
| 1936 | + { |
| 1937 | + ["managedIdentityPrincipalId"] = "msi-id", |
| 1938 | + ["agentBlueprintClientSecret"] = "secret-123" |
| 1939 | + }; |
| 1940 | + |
| 1941 | + await File.WriteAllTextAsync(generatedConfigPath, existingConfig.ToJsonString( |
| 1942 | + new JsonSerializerOptions { WriteIndented = true })); |
| 1943 | + |
| 1944 | + // Act |
| 1945 | + var generatedConfig = JsonNode.Parse( |
| 1946 | + await File.ReadAllTextAsync(generatedConfigPath))?.AsObject() ?? new JsonObject(); |
| 1947 | + |
| 1948 | + generatedConfig["agentBlueprintId"] = "app-id"; |
| 1949 | + generatedConfig["agentBlueprintObjectId"] = "obj-id"; |
| 1950 | + generatedConfig["agentBlueprintServicePrincipalObjectId"] = "sp-id"; |
| 1951 | + if (generatedConfig["resourceConsents"] == null) |
| 1952 | + { |
| 1953 | + generatedConfig["resourceConsents"] = new JsonArray(); |
| 1954 | + } |
| 1955 | + |
| 1956 | + await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString( |
| 1957 | + new JsonSerializerOptions { WriteIndented = true })); |
| 1958 | + |
| 1959 | + // Assert |
| 1960 | + var savedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath))!.AsObject(); |
| 1961 | + |
| 1962 | + savedConfig["resourceConsents"].Should().NotBeNull(); |
| 1963 | + savedConfig["resourceConsents"]!.AsArray().Should().BeEmpty(); |
| 1964 | + savedConfig["agentBlueprintClientSecret"]!.GetValue<string>().Should().Be("secret-123"); |
| 1965 | + savedConfig["managedIdentityPrincipalId"]!.GetValue<string>().Should().Be("msi-id"); |
| 1966 | + } |
| 1967 | + finally |
| 1968 | + { |
| 1969 | + if (Directory.Exists(tempDir)) |
| 1970 | + { |
| 1971 | + Directory.Delete(tempDir, recursive: true); |
| 1972 | + } |
| 1973 | + } |
| 1974 | + } |
| 1975 | + |
| 1976 | + #endregion |
1827 | 1977 | } |
0 commit comments