From e17888fc801dc44eae22fde2f22b01f12dd3d3c4 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 10 Sep 2026 16:47:36 +0800 Subject: [PATCH 1/3] precedence is determined by configuration-provider order across both schemas --- .../ConfigurationFeatureDefinitionProvider.cs | 147 +++++++++++----- ...urationFeatureDefinitionProviderOptions.cs | 15 +- .../FeatureManagementTest.cs | 161 ++++++++++++++++++ 3 files changed, 275 insertions(+), 48 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 72ed0757..e7b5de7f 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -23,6 +23,7 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP private readonly ConfigurationFeatureDefinitionProviderOptions _options; private IEnumerable _dotnetFeatureDefinitionSections; private IEnumerable _microsoftFeatureDefinitionSections; + private IDictionary _featureDefinitionSchemas; private readonly ConcurrentDictionary> _definitions; private IDisposable _changeSubscription; private int _stale = 0; @@ -31,6 +32,12 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP const string ParseValueErrorString = "Invalid setting '{0}' with value '{1}' for feature '{2}'."; + private enum FeatureDefinitionSchema + { + Dotnet, + Microsoft + } + /// /// Creates a configuration feature definition provider. /// @@ -58,7 +65,7 @@ public ConfigurationFeatureDefinitionProvider( _getFeatureDefinitionFunc = (featureName) => { - return Task.FromResult(GetMicrosoftSchemaFeatureDefinition(featureName) ?? GetDotnetSchemaFeatureDefinition(featureName)); + return Task.FromResult(GetFeatureDefinition(featureName)); }; } @@ -103,9 +110,7 @@ public Task GetFeatureDefinitionAsync(string featureName) if (Interlocked.Exchange(ref _stale, 0) != 0) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); - - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + LoadFeatureDefinitionSections(); _definitions.Clear(); } @@ -128,18 +133,21 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() if (Interlocked.Exchange(ref _stale, 0) != 0) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); - - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + LoadFeatureDefinitionSections(); _definitions.Clear(); } + HashSet processedFeatureNames = _options.CustomConfigurationMergingEnabled + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : null; + foreach (IConfigurationSection featureSection in _microsoftFeatureDefinitionSections) { string featureName = featureSection[MicrosoftFeatureManagementFields.Id]; - if (string.IsNullOrEmpty(featureName)) + if (string.IsNullOrEmpty(featureName) || + (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) { continue; } @@ -158,7 +166,8 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() { string featureName = featureSection.Key; - if (string.IsNullOrEmpty(featureName)) + if (string.IsNullOrEmpty(featureName) || + (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) { continue; } @@ -178,12 +187,57 @@ private void EnsureInit() { if (_initialized == 0) { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); + LoadFeatureDefinitionSections(); + _initialized = 1; + } + } + + private void LoadFeatureDefinitionSections() + { + _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); + + if (!_options.CustomConfigurationMergingEnabled) + { _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); + _featureDefinitionSchemas = null; + return; + } - _initialized = 1; + var microsoftFeatureDefinitionSections = new List(); + var featureDefinitionSchemas = new Dictionary(StringComparer.OrdinalIgnoreCase); + + FindFeatureDefinitions(_configuration, microsoftFeatureDefinitionSections, featureDefinitionSchemas); + + // + // Root configuration fallback definitions cannot conflict with Microsoft schema definitions. + foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections) + { + if (!featureDefinitionSchemas.ContainsKey(featureSection.Key)) + { + featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; + } + } + + _microsoftFeatureDefinitionSections = microsoftFeatureDefinitionSections; + _featureDefinitionSchemas = featureDefinitionSchemas; + } + + private FeatureDefinition GetFeatureDefinition(string featureName) + { + if (!_options.CustomConfigurationMergingEnabled) + { + return GetMicrosoftSchemaFeatureDefinition(featureName) ?? GetDotnetSchemaFeatureDefinition(featureName); + } + + if (!_featureDefinitionSchemas.TryGetValue(featureName, out FeatureDefinitionSchema schema)) + { + return null; } + + return schema == FeatureDefinitionSchema.Microsoft + ? GetMicrosoftSchemaFeatureDefinition(featureName) + : GetDotnetSchemaFeatureDefinition(featureName); } private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName) @@ -239,35 +293,21 @@ private IEnumerable GetDotnetFeatureDefinitionSections() private IEnumerable GetMicrosoftFeatureDefinitionSections() { - if (!_options.CustomConfigurationMergingEnabled) - { - return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) - .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName) - .GetChildren(); - } - - var featureDefinitionSections = new List(); - - FindFeatureFlags(_configuration, featureDefinitionSections); - - return featureDefinitionSections; + return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) + .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName) + .GetChildren(); } - private void FindFeatureFlags(IConfiguration configuration, List featureDefinitionSections) + private void FindFeatureDefinitions( + IConfiguration configuration, + List microsoftFeatureDefinitionSections, + IDictionary featureDefinitionSchemas) { if (!(configuration is IConfigurationRoot configurationRoot) || configurationRoot.Providers.Any(provider => !(provider is ConfigurationProvider) && !(provider is ChainedConfigurationProvider))) { - IConfigurationSection featureFlagsSection = configuration - .GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) - .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName); - - if (featureFlagsSection.Exists()) - { - featureDefinitionSections.AddRange(featureFlagsSection.GetChildren()); - } - + AddFeatureDefinitions(configuration, microsoftFeatureDefinitionSections, featureDefinitionSchemas); return; } @@ -281,18 +321,41 @@ private void FindFeatureFlags(IConfiguration configuration, List microsoftFeatureDefinitionSections, + IDictionary featureDefinitionSchemas) + { + IConfigurationSection dotnetFeatureManagementSection = configuration + .GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName); + + foreach (IConfigurationSection featureSection in dotnetFeatureManagementSection.GetChildren()) + { + featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; + } + + IConfigurationSection microsoftFeatureFlagsSection = configuration + .GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) + .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName); + + foreach (IConfigurationSection featureSection in microsoftFeatureFlagsSection.GetChildren()) + { + microsoftFeatureDefinitionSections.Add(featureSection); + + string featureName = featureSection[MicrosoftFeatureManagementFields.Id]; + + if (!string.IsNullOrEmpty(featureName)) + { + featureDefinitionSchemas[featureName] = FeatureDefinitionSchema.Microsoft; } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs index 3893edc4..f36b3e3b 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs @@ -9,14 +9,17 @@ namespace Microsoft.FeatureManagement public class ConfigurationFeatureDefinitionProviderOptions { /// - /// Controls whether to enable the custom configuration merging logic for Microsoft schema feature flags or fall back to .NET's native configuration merging behavior. + /// Controls whether to enable custom configuration merging for feature flags from multiple configuration sources. /// /// - /// This option only affects Microsoft schema feature flags (e.g. feature_management:feature_flags arrays). .NET schema feature flags are not affected by this setting. - /// - /// The uses custom configuration merging logic for Microsoft schema feature flags to ensure that - /// feature flags with the same ID from different configuration sources are merged correctly based on their logical identity rather than array position. - /// By default, the provider bypasses .NET's native array merging behavior which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. + /// The uses custom configuration merging logic to ensure that feature flags with the same ID from + /// different configuration sources are merged correctly based on their logical identity rather than array position. The last configuration source that + /// defines a feature flag wins, even when earlier and later sources use different feature management schemas. If the same configuration source defines a + /// feature flag in both the .NET schema and the Microsoft schema, the Microsoft schema definition takes precedence. + /// + /// .NET schema feature flags continue to use .NET's native configuration merging behavior within that schema. + /// When custom merging is enabled, the provider bypasses .NET's native array merging behavior which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. + /// When custom merging is disabled, Microsoft schema definitions take precedence over .NET schema definitions regardless of configuration source order. /// /// Consider the following configuration sources: /// Configuration Source 1: diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index a2aaeed1..e485eacf 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -255,6 +255,167 @@ public async Task RespectsAllFeatureManagementSchemas() Assert.True(await featureManager.IsEnabledAsync("FeatureZ")); } + [Fact] + public async Task CustomMergingUsesConfigurationSourceOrderAcrossSchemas() + { + var microsoftSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }; + var dotnetSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:crossschemafeature:EnabledFor:0:Name"] = "Test" + }; + var dotnetSchemaFeatureParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:crossschemafeature:EnabledFor:0:Parameters:Source"] = "Dotnet" + }; + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + IConfiguration microsoftThenDotnet = new ConfigurationBuilder() + .AddInMemoryCollection(microsoftSchemaFeature) + .AddInMemoryCollection(dotnetSchemaFeature) + .AddInMemoryCollection(dotnetSchemaFeatureParameters) + .Build(); + + using (var provider = new ConfigurationFeatureDefinitionProvider(microsoftThenDotnet, mergeOptions)) + { + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CROSSSCHEMAFEATURE"); + FeatureFilterConfiguration filter = Assert.Single(definition.EnabledFor); + + Assert.Equal(FeatureStatus.Conditional, definition.Status); + Assert.Equal("Test", filter.Name); + Assert.Equal("Dotnet", filter.Parameters["Source"]); + } + + IConfiguration dotnetThenMicrosoft = new ConfigurationBuilder() + .AddInMemoryCollection(dotnetSchemaFeature) + .AddInMemoryCollection(microsoftSchemaFeature) + .Build(); + + using (var provider = new ConfigurationFeatureDefinitionProvider(dotnetThenMicrosoft, mergeOptions)) + { + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + Assert.Empty(definition.EnabledFor); + } + } + + [Fact] + public async Task CustomMergingPrefersMicrosoftSchemaWithinSameConfigurationSource() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:CrossSchemaFeature"] = bool.TrueString, + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .Build(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + } + + [Fact] + public async Task DefaultMergingContinuesToPreferMicrosoftSchema() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:CrossSchemaFeature"] = bool.TrueString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("CrossSchemaFeature"); + + Assert.Equal(FeatureStatus.Disabled, definition.Status); + } + + [Fact] + public async Task CustomMergingDeduplicatesCrossSchemaFeaturesFromChainedConfiguration() + { + IConfiguration innerConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .Build(); + IConfiguration configuration = new ConfigurationBuilder() + .AddConfiguration(innerConfiguration) + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["FeatureManagement:crossschemafeature"] = bool.TrueString + }) + .Build(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + var definitions = new List(); + + await foreach (FeatureDefinition definition in provider.GetAllFeatureDefinitionsAsync()) + { + definitions.Add(definition); + } + + FeatureDefinition crossSchemaDefinition = Assert.Single(definitions); + Assert.Equal(FeatureStatus.Conditional, crossSchemaDefinition.Status); + Assert.Single(crossSchemaDefinition.EnabledFor); + } + + [Fact] + public async Task CustomMergingRecalculatesSchemaPrecedenceAfterReload() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "ReloadFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection() + .Build(); + IConfigurationProvider lastConfigurationProvider = configuration.Providers.Last(); + var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }; + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, mergeOptions); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("ReloadFeature"); + Assert.Equal(FeatureStatus.Disabled, definition.Status); + + lastConfigurationProvider.Set("FeatureManagement:ReloadFeature", bool.TrueString); + configuration.Reload(); + + definition = await provider.GetFeatureDefinitionAsync("ReloadFeature"); + Assert.Equal(FeatureStatus.Conditional, definition.Status); + Assert.Single(definition.EnabledFor); + } + [Fact] public async Task ThrowsForMissingFeatures() { From afbc61db5bf4921ef83231e567417cb2f2406dd7 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 10 Sep 2026 17:27:38 +0800 Subject: [PATCH 2/3] update --- .../ConfigurationFeatureDefinitionProvider.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index e7b5de7f..b720f94f 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -211,12 +211,9 @@ private void LoadFeatureDefinitionSections() // // Root configuration fallback definitions cannot conflict with Microsoft schema definitions. - foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections) + foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections.Where(section => !featureDefinitionSchemas.ContainsKey(section.Key))) { - if (!featureDefinitionSchemas.ContainsKey(featureSection.Key)) - { - featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; - } + featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; } _microsoftFeatureDefinitionSections = microsoftFeatureDefinitionSections; From 84d52308ea1e80a91e656b4c28f6da2a0995acbf Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Sun, 20 Sep 2026 10:34:18 +0800 Subject: [PATCH 3/3] group sections by source --- .../ConfigurationFeatureDefinitionProvider.cs | 197 ++++------- ...urationFeatureDefinitionProviderOptions.cs | 9 +- .../FeatureManagementTest.cs | 313 +++++++++++++++++- 3 files changed, 368 insertions(+), 151 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index b720f94f..c71a7ed4 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -21,9 +21,7 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP { private readonly IConfiguration _configuration; private readonly ConfigurationFeatureDefinitionProviderOptions _options; - private IEnumerable _dotnetFeatureDefinitionSections; - private IEnumerable _microsoftFeatureDefinitionSections; - private IDictionary _featureDefinitionSchemas; + private IEnumerable _featureDefinitionSources; private readonly ConcurrentDictionary> _definitions; private IDisposable _changeSubscription; private int _stale = 0; @@ -32,10 +30,19 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP const string ParseValueErrorString = "Invalid setting '{0}' with value '{1}' for feature '{2}'."; - private enum FeatureDefinitionSchema + private sealed class FeatureDefinitionSectionsBySource { - Dotnet, - Microsoft + public FeatureDefinitionSectionsBySource( + IEnumerable dotnetSections, + IEnumerable microsoftSections) + { + DotnetSections = dotnetSections; + MicrosoftSections = microsoftSections; + } + + public IEnumerable DotnetSections { get; } + + public IEnumerable MicrosoftSections { get; } } /// @@ -142,43 +149,26 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() ? new HashSet(StringComparer.OrdinalIgnoreCase) : null; - foreach (IConfigurationSection featureSection in _microsoftFeatureDefinitionSections) - { - string featureName = featureSection[MicrosoftFeatureManagementFields.Id]; - - if (string.IsNullOrEmpty(featureName) || - (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) - { - continue; - } - - // - // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; - - if (definition != null) - { - yield return definition; - } - } - - foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections) + foreach (FeatureDefinitionSectionsBySource source in _featureDefinitionSources) { - string featureName = featureSection.Key; + IEnumerable featureNames = source.MicrosoftSections + .Select(section => section[MicrosoftFeatureManagementFields.Id]) + .Concat(source.DotnetSections.Select(section => section.Key)); - if (string.IsNullOrEmpty(featureName) || - (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) + foreach (string featureName in featureNames) { - continue; - } + if (string.IsNullOrEmpty(featureName) || + (processedFeatureNames != null && !processedFeatureNames.Add(featureName))) + { + continue; + } - // - // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; + FeatureDefinition definition = _definitions.GetOrAdd(featureName, _getFeatureDefinitionFunc).Result; - if (definition != null) - { - yield return definition; + if (definition != null) + { + yield return definition; + } } } } @@ -195,51 +185,45 @@ private void EnsureInit() private void LoadFeatureDefinitionSections() { - _dotnetFeatureDefinitionSections = GetDotnetFeatureDefinitionSections(); + // + // Determine root fallback from the full configuration, not from individual providers. + bool useRootConfiguration = RootConfigurationFallbackEnabled && + !_configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName).Exists() && + !_configuration.GetChildren().Any(section => + string.Equals(section.Key, MicrosoftFeatureManagementFields.FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)); if (!_options.CustomConfigurationMergingEnabled) { - _microsoftFeatureDefinitionSections = GetMicrosoftFeatureDefinitionSections(); - _featureDefinitionSchemas = null; + _featureDefinitionSources = new[] { GetFeatureDefinitionSections(_configuration, useRootConfiguration) }; return; } - var microsoftFeatureDefinitionSections = new List(); - var featureDefinitionSchemas = new Dictionary(StringComparer.OrdinalIgnoreCase); - - FindFeatureDefinitions(_configuration, microsoftFeatureDefinitionSections, featureDefinitionSchemas); + var featureDefinitionSources = new List(); - // - // Root configuration fallback definitions cannot conflict with Microsoft schema definitions. - foreach (IConfigurationSection featureSection in _dotnetFeatureDefinitionSections.Where(section => !featureDefinitionSchemas.ContainsKey(section.Key))) - { - featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; - } + FindFeatureDefinitionSources(_configuration, useRootConfiguration, featureDefinitionSources); - _microsoftFeatureDefinitionSections = microsoftFeatureDefinitionSections; - _featureDefinitionSchemas = featureDefinitionSchemas; + _featureDefinitionSources = featureDefinitionSources; } private FeatureDefinition GetFeatureDefinition(string featureName) { - if (!_options.CustomConfigurationMergingEnabled) + foreach (FeatureDefinitionSectionsBySource source in _featureDefinitionSources) { - return GetMicrosoftSchemaFeatureDefinition(featureName) ?? GetDotnetSchemaFeatureDefinition(featureName); - } + FeatureDefinition definition = GetMicrosoftSchemaFeatureDefinition(featureName, source.MicrosoftSections) ?? + GetDotnetSchemaFeatureDefinition(featureName, source.DotnetSections); - if (!_featureDefinitionSchemas.TryGetValue(featureName, out FeatureDefinitionSchema schema)) - { - return null; + if (definition != null) + { + return definition; + } } - return schema == FeatureDefinitionSchema.Microsoft - ? GetMicrosoftSchemaFeatureDefinition(featureName) - : GetDotnetSchemaFeatureDefinition(featureName); + return null; } - private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName) + private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName, IEnumerable sections) { - IConfigurationSection dotnetFeatureDefinitionConfiguration = _dotnetFeatureDefinitionSections + IConfigurationSection dotnetFeatureDefinitionConfiguration = sections .FirstOrDefault(section => string.Equals(section.Key, featureName, StringComparison.OrdinalIgnoreCase)); @@ -251,9 +235,9 @@ private FeatureDefinition GetDotnetSchemaFeatureDefinition(string featureName) return ParseDotnetSchemaFeatureDefinition(dotnetFeatureDefinitionConfiguration); } - private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName) + private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName, IEnumerable sections) { - IConfigurationSection microsoftFeatureDefinitionConfiguration = _microsoftFeatureDefinitionSections + IConfigurationSection microsoftFeatureDefinitionConfiguration = sections .LastOrDefault(section => string.Equals(section[MicrosoftFeatureManagementFields.Id], featureName, StringComparison.OrdinalIgnoreCase)); @@ -265,50 +249,33 @@ private FeatureDefinition GetMicrosoftSchemaFeatureDefinition(string featureName return ParseMicrosoftSchemaFeatureDefinition(microsoftFeatureDefinitionConfiguration); } - private IEnumerable GetDotnetFeatureDefinitionSections() - { - IConfigurationSection featureManagementConfigurationSection = _configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName); - - if (featureManagementConfigurationSection.Exists()) - { - return featureManagementConfigurationSection.GetChildren(); - } - - // - // Root configuration fallback only applies to .NET schema. - // If Microsoft schema can be found, root configuration fallback will not be effective. - if (RootConfigurationFallbackEnabled && - !_configuration.GetChildren() - .Any(section => - string.Equals(section.Key, MicrosoftFeatureManagementFields.FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) - { - return _configuration.GetChildren(); - } - - return Enumerable.Empty(); - } - - private IEnumerable GetMicrosoftFeatureDefinitionSections() + private FeatureDefinitionSectionsBySource GetFeatureDefinitionSections(IConfiguration configuration, bool useRootConfiguration) { - return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) - .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName) - .GetChildren(); + return new FeatureDefinitionSectionsBySource( + useRootConfiguration + ? configuration.GetChildren() + : configuration.GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName).GetChildren(), + configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) + .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName) + .GetChildren()); } - private void FindFeatureDefinitions( + private void FindFeatureDefinitionSources( IConfiguration configuration, - List microsoftFeatureDefinitionSections, - IDictionary featureDefinitionSchemas) + bool useRootConfiguration, + List featureDefinitionSources) { if (!(configuration is IConfigurationRoot configurationRoot) || configurationRoot.Providers.Any(provider => !(provider is ConfigurationProvider) && !(provider is ChainedConfigurationProvider))) { - AddFeatureDefinitions(configuration, microsoftFeatureDefinitionSections, featureDefinitionSchemas); + featureDefinitionSources.Add(GetFeatureDefinitionSections(configuration, useRootConfiguration)); return; } - foreach (IConfigurationProvider provider in configurationRoot.Providers) + // + // Keep sources in highest-to-lowest precedence order, including chained providers. + foreach (IConfigurationProvider provider in configurationRoot.Providers.Reverse()) { if (provider is ConfigurationProvider configurationProvider) { @@ -318,41 +285,11 @@ private void FindFeatureDefinitions( var onDemandConfigurationRoot = new ConfigurationRoot(new[] { onDemandConfigurationProvider }); - AddFeatureDefinitions(onDemandConfigurationRoot, microsoftFeatureDefinitionSections, featureDefinitionSchemas); + featureDefinitionSources.Add(GetFeatureDefinitionSections(onDemandConfigurationRoot, useRootConfiguration)); } else if (provider is ChainedConfigurationProvider chainedProvider) { - FindFeatureDefinitions(chainedProvider.Configuration, microsoftFeatureDefinitionSections, featureDefinitionSchemas); - } - } - } - - private void AddFeatureDefinitions( - IConfiguration configuration, - List microsoftFeatureDefinitionSections, - IDictionary featureDefinitionSchemas) - { - IConfigurationSection dotnetFeatureManagementSection = configuration - .GetSection(DotnetFeatureManagementFields.FeatureManagementSectionName); - - foreach (IConfigurationSection featureSection in dotnetFeatureManagementSection.GetChildren()) - { - featureDefinitionSchemas[featureSection.Key] = FeatureDefinitionSchema.Dotnet; - } - - IConfigurationSection microsoftFeatureFlagsSection = configuration - .GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName) - .GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName); - - foreach (IConfigurationSection featureSection in microsoftFeatureFlagsSection.GetChildren()) - { - microsoftFeatureDefinitionSections.Add(featureSection); - - string featureName = featureSection[MicrosoftFeatureManagementFields.Id]; - - if (!string.IsNullOrEmpty(featureName)) - { - featureDefinitionSchemas[featureName] = FeatureDefinitionSchema.Microsoft; + FindFeatureDefinitionSources(chainedProvider.Configuration, useRootConfiguration, featureDefinitionSources); } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs index f36b3e3b..13bfa75f 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProviderOptions.cs @@ -17,9 +17,12 @@ public class ConfigurationFeatureDefinitionProviderOptions /// defines a feature flag wins, even when earlier and later sources use different feature management schemas. If the same configuration source defines a /// feature flag in both the .NET schema and the Microsoft schema, the Microsoft schema definition takes precedence. /// - /// .NET schema feature flags continue to use .NET's native configuration merging behavior within that schema. - /// When custom merging is enabled, the provider bypasses .NET's native array merging behavior which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. - /// When custom merging is disabled, Microsoft schema definitions take precedence over .NET schema definitions regardless of configuration source order. + /// When custom merging is enabled, both .NET and Microsoft schema feature flag definitions are selected as a whole from the winning source. + /// Properties, feature filters, and filter parameters are not merged across sources. Each overriding source must supply the complete intended definition. + /// This bypasses .NET's native array merging behavior, which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources. + /// For configuration that cannot be inspected provider by provider, the combined configuration is treated as a single source. + /// When custom merging is disabled (the default), .NET's native configuration merging behavior is used, and Microsoft schema definitions take precedence + /// over .NET schema definitions regardless of configuration source order. /// /// Consider the following configuration sources: /// Configuration Source 1: diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index e485eacf..701e5c8c 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -75,8 +75,10 @@ public async Task ReadsConfiguration() Assert.True(hasItems); } - [Fact] - public async Task ReadsTopLevelConfiguration() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReadsTopLevelConfiguration(bool customMergingEnabled) { var stream = new MemoryStream(Encoding.UTF8.GetBytes($"{{\"AllowedHosts\": \"*\", \"FeatureFlags\": {{\"FeatureX\": true}}}}")); @@ -84,6 +86,8 @@ public async Task ReadsTopLevelConfiguration() var services = new ServiceCollection(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = customMergingEnabled); services.AddFeatureManagement(config.GetSection("FeatureFlags")); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -113,6 +117,8 @@ public async Task ReadsTopLevelConfiguration() services = new ServiceCollection(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = customMergingEnabled); services.AddFeatureManagement(config.GetSection("FeatureFlags")); serviceProvider = services.BuildServiceProvider(); @@ -265,10 +271,7 @@ public async Task CustomMergingUsesConfigurationSourceOrderAcrossSchemas() }; var dotnetSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["FeatureManagement:crossschemafeature:EnabledFor:0:Name"] = "Test" - }; - var dotnetSchemaFeatureParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { + ["FeatureManagement:crossschemafeature:EnabledFor:0:Name"] = "Test", ["FeatureManagement:crossschemafeature:EnabledFor:0:Parameters:Source"] = "Dotnet" }; var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions @@ -279,7 +282,6 @@ public async Task CustomMergingUsesConfigurationSourceOrderAcrossSchemas() IConfiguration microsoftThenDotnet = new ConfigurationBuilder() .AddInMemoryCollection(microsoftSchemaFeature) .AddInMemoryCollection(dotnetSchemaFeature) - .AddInMemoryCollection(dotnetSchemaFeatureParameters) .Build(); using (var provider = new ConfigurationFeatureDefinitionProvider(microsoftThenDotnet, mergeOptions)) @@ -351,23 +353,38 @@ public async Task DefaultMergingContinuesToPreferMicrosoftSchema() Assert.Equal(FeatureStatus.Disabled, definition.Status); } - [Fact] - public async Task CustomMergingDeduplicatesCrossSchemaFeaturesFromChainedConfiguration() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CustomMergingDeduplicatesCrossSchemaFeaturesFromChainedConfiguration(bool chainedSourceLast) { + var microsoftSchemaFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }; IConfiguration innerConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(microsoftSchemaFeature) .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["feature_management:feature_flags:0:id"] = "CrossSchemaFeature", - ["feature_management:feature_flags:0:enabled"] = bool.FalseString + ["FeatureManagement:crossschemafeature"] = bool.TrueString }) .Build(); - IConfiguration configuration = new ConfigurationBuilder() + IConfiguration chainedConfiguration = new ConfigurationBuilder() .AddConfiguration(innerConfiguration) - .AddInMemoryCollection(new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["FeatureManagement:crossschemafeature"] = bool.TrueString - }) .Build(); + var builder = new ConfigurationBuilder(); + + if (chainedSourceLast) + { + builder.AddInMemoryCollection(microsoftSchemaFeature).AddConfiguration(chainedConfiguration); + } + else + { + builder.AddConfiguration(chainedConfiguration).AddInMemoryCollection(microsoftSchemaFeature); + } + + IConfiguration configuration = builder.Build(); var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions { CustomConfigurationMergingEnabled = true @@ -382,8 +399,18 @@ public async Task CustomMergingDeduplicatesCrossSchemaFeaturesFromChainedConfigu } FeatureDefinition crossSchemaDefinition = Assert.Single(definitions); - Assert.Equal(FeatureStatus.Conditional, crossSchemaDefinition.Status); - Assert.Single(crossSchemaDefinition.EnabledFor); + Assert.Equal(chainedSourceLast ? FeatureStatus.Conditional : FeatureStatus.Disabled, crossSchemaDefinition.Status); + + var featureManager = new FeatureManager(provider); + var featureNames = new List(); + + await foreach (string featureName in featureManager.GetFeatureNamesAsync()) + { + featureNames.Add(featureName); + } + + Assert.Single(featureNames); + Assert.Equal(chainedSourceLast, await featureManager.IsEnabledAsync("CROSSSCHEMAFEATURE")); } [Fact] @@ -414,6 +441,256 @@ public async Task CustomMergingRecalculatesSchemaPrecedenceAfterReload() definition = await provider.GetFeatureDefinitionAsync("ReloadFeature"); Assert.Equal(FeatureStatus.Conditional, definition.Status); Assert.Single(definition.EnabledFor); + + lastConfigurationProvider.Set("feature_management:feature_flags:0:id", "ReloadFeature"); + lastConfigurationProvider.Set("feature_management:feature_flags:0:enabled", bool.FalseString); + configuration.Reload(); + + var definitions = new List(); + + await foreach (FeatureDefinition currentDefinition in provider.GetAllFeatureDefinitionsAsync()) + { + definitions.Add(currentDefinition); + } + + Assert.Equal(FeatureStatus.Disabled, Assert.Single(definitions).Status); + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("ReloadFeature")).Status); + Assert.Null(await provider.GetFeatureDefinitionAsync("RenamedFeature")); + + lastConfigurationProvider.Set("feature_management:feature_flags:0:id", "RenamedFeature"); + configuration.Reload(); + + Assert.Equal(FeatureStatus.Conditional, (await provider.GetFeatureDefinitionAsync("ReloadFeature")).Status); + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("RenamedFeature")).Status); + } + + [Fact] + public async Task CustomMergingDoesNotInheritEarlierAlwaysOnValue() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:TargetedFeature"] = bool.TrueString + }) + .AddInMemoryCollection(new Dictionary + { + ["feature_management:feature_flags:0:id"] = "TargetedFeature", + ["feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:TargetedFeature:EnabledFor:0:Name"] = "Microsoft.Targeting", + ["FeatureManagement:TargetedFeature:EnabledFor:0:Parameters:Audience:Users:0"] = "Alice", + ["FeatureManagement:TargetedFeature:EnabledFor:0:Parameters:Audience:DefaultRolloutPercentage"] = "0" + }) + .Build(); + var services = new ServiceCollection(); + + services.AddSingleton(configuration).AddFeatureManagement(); + services.Configure(options => + options.CustomConfigurationMergingEnabled = true); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync("TargetedFeature", new TargetingContext { UserId = "Alice" })); + Assert.False(await featureManager.IsEnabledAsync("TargetedFeature", new TargetingContext { UserId = "Bob" })); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MergesDotnetPropertiesOnlyWhenCustomMergingIsDisabled(bool customMergingEnabled) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:RequirementType"] = "All", + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Inherited"] = "Earlier", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Shared"] = "Earlier", + ["FeatureManagement:Feature:EnabledFor:1:Name"] = "AlwaysOn" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test", + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Shared"] = "Later" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = customMergingEnabled + }); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("Feature"); + List filters = definition.EnabledFor.ToList(); + + Assert.Equal(customMergingEnabled ? RequirementType.Any : RequirementType.All, definition.RequirementType); + Assert.Equal(customMergingEnabled ? 1 : 2, filters.Count); + Assert.Equal("Test", filters[0].Name); + Assert.Equal("Later", filters[0].Parameters["Shared"]); + Assert.Equal(customMergingEnabled ? null : "Earlier", filters[0].Parameters["Inherited"]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MergesPartialDotnetDefinitionsOnlyWhenCustomMergingIsDisabled(bool customMergingEnabled) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "Test" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Parameters:Source"] = "Later" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = customMergingEnabled + }); + + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync("Feature"); + + if (customMergingEnabled) + { + Assert.Empty(definition.EnabledFor); + } + else + { + FeatureFilterConfiguration filter = Assert.Single(definition.EnabledFor); + + Assert.Equal("Test", filter.Name); + Assert.Equal("Later", filter.Parameters["Source"]); + } + } + + [Fact] + public async Task CustomMergingDoesNotInheritFiltersForDisabledDotnetDefinition() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature:EnabledFor:0:Name"] = "AlwaysOn" + }) + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature"] = bool.FalseString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + var featureManager = new FeatureManager(provider); + + Assert.Empty((await provider.GetFeatureDefinitionAsync("Feature")).EnabledFor); + Assert.False(await featureManager.IsEnabledAsync("Feature")); + } + + [Fact] + public async Task CustomMergingTreatsConfigurationSectionAsOneSource() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Flags:feature_management:feature_flags:0:id"] = "Feature", + ["Flags:feature_management:feature_flags:0:enabled"] = bool.FalseString + }) + .AddInMemoryCollection(new Dictionary + { + ["Flags:FeatureManagement:Feature"] = bool.TrueString + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration.GetSection("Flags"), new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("Feature")).Status); + } + + [Fact] + public async Task CustomMergingParsesOnlyRequestedWinningDefinitions() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:Feature"] = bool.TrueString, + ["feature_management:feature_flags:0:id"] = "Feature", + ["feature_management:feature_flags:0:enabled"] = "Invalid", + ["feature_management:feature_flags:1:id"] = "Feature", + ["feature_management:feature_flags:1:enabled"] = bool.FalseString, + ["feature_management:feature_flags:2:id"] = "UnrequestedFeature", + ["feature_management:feature_flags:2:enabled"] = "Invalid" + }) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }); + + Assert.Equal(FeatureStatus.Disabled, (await provider.GetFeatureDefinitionAsync("Feature")).Status); + Assert.Null(await provider.GetFeatureDefinitionAsync("MissingFeature")); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task CustomMergingDeterminesRootFallbackAcrossAllSources(bool hasDotnetSection, bool hasMicrosoftSection) + { + var laterSource = new Dictionary + { + ["RootFeature"] = bool.FalseString + }; + + if (hasDotnetSection) + { + laterSource["FeatureManagement:DotnetFeature"] = bool.TrueString; + } + + if (hasMicrosoftSection) + { + laterSource["feature_management:feature_flags:0:id"] = "MicrosoftFeature"; + laterSource["feature_management:feature_flags:0:enabled"] = bool.TrueString; + } + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["RootFeature"] = bool.TrueString + }) + .AddInMemoryCollection(laterSource) + .Build(); + + using var provider = new ConfigurationFeatureDefinitionProvider(configuration, new ConfigurationFeatureDefinitionProviderOptions + { + CustomConfigurationMergingEnabled = true + }) + { + RootConfigurationFallbackEnabled = true + }; + + FeatureDefinition rootFeature = await provider.GetFeatureDefinitionAsync("RootFeature"); + + if (hasDotnetSection || hasMicrosoftSection) + { + Assert.Null(rootFeature); + Assert.NotNull(await provider.GetFeatureDefinitionAsync(hasDotnetSection ? "DotnetFeature" : "MicrosoftFeature")); + } + else + { + Assert.NotNull(rootFeature); + Assert.Empty(rootFeature.EnabledFor); + } } [Fact]