diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..538c95f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# If there are abnormal line endings in any file, run "git add --renormalize ", +# review the changes, and commit them to fix the line endings. +* text=auto diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..0eb636a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,92 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main", "preview", "release/*" ] + pull_request: + branches: [ "main", "preview", "release/*" ] + schedule: + - cron: '21 11 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/LICENSE b/LICENSE index 4b1ad51b..21071075 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ - MIT License - - Copyright (c) Microsoft Corporation. All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/README.md b/README.md index b4a2bc6c..b1dee8ab 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,38 @@ -# .NET Feature Management - -[![Microsoft.FeatureManagement](https://img.shields.io/nuget/v/Microsoft.FeatureManagement?label=Microsoft.FeatureManagement)](https://www.nuget.org/packages/Microsoft.FeatureManagement) -[![Microsoft.FeatureManagement.AspNetCore](https://img.shields.io/nuget/v/Microsoft.FeatureManagement.AspNetCore?label=Microsoft.FeatureManagement.AspNetCore)](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore) - -Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. - -## Get started - -[**Quickstart**](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-dotnet): A quickstart guide is available to learn how to integrate feature flags from *Azure App Configuration* into your .NET applications. - -[**Feature Reference**](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference): This document provides a full feature rundown. - -[**API reference**](https://go.microsoft.com/fwlink/?linkid=2091700): This API reference details the API surface of the libraries contained within this repository. - -## Examples - -* [.NET Console App](./examples/ConsoleApp) -* [.NET Console App with Targeting](./examples/TargetingConsoleApp) -* [ASP.NET Core Web App (Razor Page)](./examples/RazorPages) -* [ASP.NET Core Web App (MVC)](./examples/FeatureFlagDemo) -* [Blazor Server App](./examples/BlazorServerApp) -* [ASP.NET Core Web App with Variants and Telemetry](./examples/VariantAndTelemetryDemo) -* [ASP.NET Core Web App with Variant Service](./examples/VariantServiceDemo) - -## Contributing - -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.microsoft.com. - -When you submit a pull request, a CLA-bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +# .NET Feature Management + +[![Microsoft.FeatureManagement](https://img.shields.io/nuget/v/Microsoft.FeatureManagement?label=Microsoft.FeatureManagement)](https://www.nuget.org/packages/Microsoft.FeatureManagement) +[![Microsoft.FeatureManagement.AspNetCore](https://img.shields.io/nuget/v/Microsoft.FeatureManagement.AspNetCore?label=Microsoft.FeatureManagement.AspNetCore)](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore) + +Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. + +## Get started + +[**Quickstart**](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-dotnet): A quickstart guide is available to learn how to integrate feature flags from *Azure App Configuration* into your .NET applications. + +[**Feature Reference**](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference): This document provides a full feature rundown. + +[**API reference**](https://go.microsoft.com/fwlink/?linkid=2091700): This API reference details the API surface of the libraries contained within this repository. + +## Examples + +* [.NET Console App](./examples/ConsoleApp) +* [.NET Console App with Targeting](./examples/TargetingConsoleApp) +* [ASP.NET Core Web App (Razor Page)](./examples/RazorPages) +* [ASP.NET Core Web App (MVC)](./examples/FeatureFlagDemo) +* [Blazor Server App](./examples/BlazorServerApp) +* [ASP.NET Core Web App with Variants and Telemetry](./examples/VariantAndTelemetryDemo) +* [ASP.NET Core Web App with Variant Service](./examples/VariantServiceDemo) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 index 355cb882..6ef5b5f7 100644 --- a/build/install-dotnet.ps1 +++ b/build/install-dotnet.ps1 @@ -1,12 +1,8 @@ -# Installs .NET 6, .NET 7 and .NET 8 for CI/CD environment +# Installs .NET 8 and .NET 9 for CI/CD environment # see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 6.0 - -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0 - &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0 &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 9.0 diff --git a/examples/ConsoleApp/Program.cs b/examples/ConsoleApp/Program.cs index fb43a50e..ab3249da 100644 --- a/examples/ConsoleApp/Program.cs +++ b/examples/ConsoleApp/Program.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; // @@ -11,45 +10,35 @@ .AddJsonFile("appsettings.json") .Build(); -// -// Setup application services + feature management -IServiceCollection services = new ServiceCollection(); +var featureManager = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration)) +{ + FeatureFilters = new List { new AccountIdFilter() } +}; -services.AddSingleton(configuration) - .AddFeatureManagement() - .AddFeatureFilter(); +var accounts = new List() +{ + "abc", + "adef", + "abcdefghijklmnopqrstuvwxyz" +}; // -// Get the feature manager from application services -using (ServiceProvider serviceProvider = services.BuildServiceProvider()) +// Mimic work items in a task-driven console application +foreach (var account in accounts) { - IFeatureManager featureManager = serviceProvider.GetRequiredService(); - - var accounts = new List() - { - "abc", - "adef", - "abcdefghijklmnopqrstuvwxyz" - }; + const string FeatureName = "Beta"; // - // Mimic work items in a task-driven console application - foreach (var account in accounts) + // Check if feature enabled + // + var accountServiceContext = new AccountServiceContext { - const string FeatureName = "Beta"; - - // - // Check if feature enabled - // - var accountServiceContext = new AccountServiceContext - { - AccountId = account - }; + AccountId = account + }; - bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext); + bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext); - // - // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the '{account}' account."); - } + // + // Output results + Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the '{account}' account."); } diff --git a/examples/FeatureFlagDemo/Controllers/HomeController.cs b/examples/FeatureFlagDemo/Controllers/HomeController.cs index e7bfcd43..b87799fc 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -31,7 +31,7 @@ public async Task About() if (await _featureManager.IsEnabledAsync(MyFeatureFlags.CustomViewData)) { ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{MyFeatureFlags.CustomViewData}' is enabled."; - }; + } return View(); } diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 02c17542..ce944300 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -1,5 +1,7 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; using Microsoft.FeatureManagement; using Microsoft.FeatureManagement.FeatureFilters; using TargetingConsoleApp.Identity; @@ -10,48 +12,39 @@ .AddJsonFile("appsettings.json") .Build(); -// -// Setup application services + feature management -IServiceCollection services = new ServiceCollection(); - -services.AddSingleton(configuration) - .AddFeatureManagement(); +var featureManager = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration)) +{ + FeatureFilters = new List { new ContextualTargetingFilter() } +}; var userRepository = new InMemoryUserRepository(); // -// Get the feature manager from application services -using (ServiceProvider serviceProvider = services.BuildServiceProvider()) +// We'll simulate a task to run on behalf of each known user +// To do this we enumerate all the users in our user repository +IEnumerable userIds = InMemoryUserRepository.Users.Select(u => u.Id); + +// +// Mimic work items in a task-driven console application +foreach (string userId in userIds) { - IFeatureManager featureManager = serviceProvider.GetRequiredService(); + const string FeatureName = "Beta"; // - // We'll simulate a task to run on behalf of each known user - // To do this we enumerate all the users in our user repository - IEnumerable userIds = InMemoryUserRepository.Users.Select(u => u.Id); + // Get user + User user = await userRepository.GetUser(userId); // - // Mimic work items in a task-driven console application - foreach (string userId in userIds) + // Check if feature enabled + var targetingContext = new TargetingContext { - const string FeatureName = "Beta"; - - // - // Get user - User user = await userRepository.GetUser(userId); - - // - // Check if feature enabled - var targetingContext = new TargetingContext - { - UserId = user.Id, - Groups = user.Groups - }; - - bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext); - - // - // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); - } + UserId = user.Id, + Groups = user.Groups + }; + + bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext); + + // + // Output results + Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); } diff --git a/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs b/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs deleted file mode 100644 index 159c172f..00000000 --- a/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Microsoft.FeatureManagement.FeatureFilters; - -namespace VariantServiceDemo -{ - /// - /// Provides an implementation of that creates a targeting context using info from the current HTTP request. - /// - public class HttpContextTargetingContextAccessor : ITargetingContextAccessor - { - private const string TargetingContextLookup = "HttpContextTargetingContextAccessor.TargetingContext"; - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - } - - public ValueTask GetContextAsync() - { - HttpContext httpContext = _httpContextAccessor.HttpContext; - - // - // Try cache lookup - if (httpContext.Items.TryGetValue(TargetingContextLookup, out object value)) - { - return new ValueTask((TargetingContext)value); - } - - // - // Grab username from cookie - string username = httpContext.Request.Cookies["username"]; - - var groups = new List(); - - // - // Build targeting context based on user info - var targetingContext = new TargetingContext - { - UserId = username, - Groups = groups - }; - - // - // Cache for subsequent lookup - httpContext.Items[TargetingContextLookup] = targetingContext; - - return new ValueTask(targetingContext); - } - } -} diff --git a/examples/VariantServiceDemo/Pages/Index.cshtml.cs b/examples/VariantServiceDemo/Pages/Index.cshtml.cs index 6c1fb59b..8e90e656 100644 --- a/examples/VariantServiceDemo/Pages/Index.cshtml.cs +++ b/examples/VariantServiceDemo/Pages/Index.cshtml.cs @@ -1,6 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.FeatureManagement; +using System.Security.Claims; namespace VariantServiceDemo.Pages { @@ -19,11 +21,22 @@ public IActionResult OnGet() { // // generate a new visitor - string visitor = Random.Shared.Next().ToString(); + Username = Random.Shared.Next().ToString(); - Response.Cookies.Append("username", visitor); + // Clear Application Insights cookies and + Response.Cookies.Delete("ai_user"); + Response.Cookies.Delete("ai_session"); - Username = visitor; + // Generate new user claim + var claims = new List + { + new Claim(ClaimTypes.Name, Username) + }; + + var identity = new ClaimsIdentity(claims, "CookieAuth"); + var principal = new ClaimsPrincipal(identity); + + HttpContext.SignInAsync("CookieAuth", principal); return Page(); } diff --git a/examples/VariantServiceDemo/Program.cs b/examples/VariantServiceDemo/Program.cs index 8ac9a031..a866d02d 100644 --- a/examples/VariantServiceDemo/Program.cs +++ b/examples/VariantServiceDemo/Program.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Microsoft.ApplicationInsights.Extensibility; using Microsoft.FeatureManagement; -using Microsoft.FeatureManagement.Telemetry.ApplicationInsights; using VariantServiceDemo; var builder = WebApplication.CreateBuilder(args); @@ -14,14 +12,12 @@ // Add services to the container. builder.Services.AddRazorPages(); -builder.Services.AddHttpContextAccessor(); +// +// Use cookie auth for simplicity and randomizing user +builder.Services.AddAuthentication("CookieAuth"); builder.Services.AddApplicationInsightsTelemetry(); -// -// App Insights TargetingId Tagging -builder.Services.AddSingleton(); - // // Add variant implementations of ICalculator builder.Services.AddSingleton(); @@ -35,7 +31,7 @@ // Including user targeting capability and the variant service provider of ICalculator which is bounded with the variant feature flag "Calculator" // Wire up evaluation event emission builder.Services.AddFeatureManagement() - .WithTargeting() + .WithTargeting() .WithVariantService("Calculator") .AddApplicationInsightsTelemetry(); diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs index 98ba2096..0c87b107 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs @@ -22,7 +22,7 @@ public class FeatureGateAttribute : ActionFilterAttribute, IAsyncPageFilter /// /// The names of the features that the attribute will represent. public FeatureGateAttribute(params string[] features) - : this(RequirementType.All, features) + : this(RequirementType.All, false, features) { } @@ -32,6 +32,27 @@ public FeatureGateAttribute(params string[] features) /// Specifies whether all or any of the provided features should be enabled in order to pass. /// The names of the features that the attribute will represent. public FeatureGateAttribute(RequirementType requirementType, params string[] features) + : this(requirementType, false, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to negate the evaluation result. + /// + /// Specifies the evaluation for the provided features gate should be negated. + /// The names of the features that the attribute will represent. + public FeatureGateAttribute(bool negate, params string[] features) + : this(RequirementType.All, negate, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass or negate the evaluation result. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies the evaluation for the provided features gate should be negated. + /// The names of the features that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, bool negate, params string[] features) { if (features == null || features.Length == 0) { @@ -41,6 +62,8 @@ public FeatureGateAttribute(RequirementType requirementType, params string[] fea Features = features; RequirementType = requirementType; + + Negate = negate; } /// @@ -48,7 +71,7 @@ public FeatureGateAttribute(RequirementType requirementType, params string[] fea /// /// A set of enums representing the features that the attribute will represent. public FeatureGateAttribute(params object[] features) - : this(RequirementType.All, features) + : this(RequirementType.All, false, features) { } @@ -58,6 +81,27 @@ public FeatureGateAttribute(params object[] features) /// Specifies whether all or any of the provided features should be enabled in order to pass. /// A set of enums representing the features that the attribute will represent. public FeatureGateAttribute(RequirementType requirementType, params object[] features) + : this(requirementType, false, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to negate the evaluation result. + /// + /// Specifies the evaluation for the provided features gate should be negated. + /// A set of enums representing the features that the attribute will represent. + public FeatureGateAttribute(bool negate, params object[] features) + : this(RequirementType.All, negate, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass or negate the evaluation result. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies the evaluation for the provided features gate should be negated. + /// A set of enums representing the features that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, bool negate, params object[] features) { if (features == null || features.Length == 0) { @@ -82,6 +126,8 @@ public FeatureGateAttribute(RequirementType requirementType, params object[] fea Features = fs; RequirementType = requirementType; + + Negate = negate; } /// @@ -94,6 +140,11 @@ public FeatureGateAttribute(RequirementType requirementType, params object[] fea /// public RequirementType RequirementType { get; } + /// + /// Negates the evaluation for whether or not a feature gate should activate. + /// + public bool Negate { get; } + /// /// Performs controller action pre-processing to ensure that any or all of the specified features are enabled. /// @@ -110,6 +161,11 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + if (Negate) + { + enabled = !enabled; + } + if (enabled) { await next().ConfigureAwait(false); @@ -138,6 +194,11 @@ public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext contex ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + if (Negate) + { + enabled = !enabled; + } + if (enabled) { await next.Invoke().ConfigureAwait(false); diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs new file mode 100644 index 00000000..fef839f5 --- /dev/null +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.AspNetCore +{ + /// + /// An endpoint filter that controls access based on feature flag states. + /// + internal sealed class FeatureGateEndpointFilter : IEndpointFilter + { + /// + /// Gets the collection of feature flags to evaluate. + /// + private readonly IEnumerable _features; + + /// + /// Gets the type of requirement (All or Any) for feature evaluation. + /// + private readonly RequirementType _requirementType; + + /// + /// Gets whether the feature evaluation result should be negated. + /// + private readonly bool _negate; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(params string[] features) + : this(RequirementType.All, negate: false, features) + { + } + + /// + /// Creates a new instance of the class. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(RequirementType requirementType, params string[] features) + : this(requirementType, negate: false, features) + { + } + + /// + /// Creates a new instance of the class. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies whether the feature evaluation result should be negated. + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(RequirementType requirementType, bool negate, params string[] features) + { + if (features == null || features.Length == 0) + { + throw new ArgumentNullException(nameof(features)); + } + + _features = features; + _requirementType = requirementType; + _negate = negate; + } + + /// + /// Invokes the feature flag filter to control endpoint access based on feature states. + /// + /// The endpoint filter invocation context. + /// The delegate representing the next filter in the pipeline. + /// + /// A result if access is denied, otherwise continues the pipeline. + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + IVariantFeatureManager fm = context.HttpContext.RequestServices.GetRequiredService(); + + bool enabled = _requirementType == RequirementType.All + ? await _features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) + : await _features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + + bool isAllowed = _negate ? !enabled : enabled; + + return isAllowed + ? await next(context).ConfigureAwait(false) + : Results.NotFound(); + } + } +} diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs new file mode 100644 index 00000000..5bd76f55 --- /dev/null +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.FeatureManagement.AspNetCore +{ + /// + /// Extension methods that provide feature management integration for ASP.NET Core endpoint building. + /// + public static class FeatureGateEndpointFilterExtensions + { + /// + /// Adds a filter to the endpoint that gates access based on whether one or more features are enabled. + /// All features must be enabled for access to be granted. + /// + /// The endpoint convention builder. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(features)); + } + + /// + /// Adds a filter to the endpoint with specified requirement type for multiple features. + /// + /// The endpoint convention builder. + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, features)); + } + + /// + /// Adds a filter to the endpoint with negation capability for multiple features. + /// + /// The endpoint convention builder. + /// Specifies whether the feature evaluation result should be negated. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, bool negate, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(RequirementType.All, negate, features)); + } + + /// + /// Adds a filter to the endpoint with full control over requirement type and negation. + /// + /// The endpoint convention builder. + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies whether the feature evaluation result should be negated. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, bool negate, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, negate, features)); + } + } +} diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 50a0e702..ea246f2f 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -5,14 +5,14 @@ 4 - 0 + 1 0 - net6.0;net8.0;net9.0 + net8.0;net9.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs index 4699c543..594b04e5 100644 --- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs +++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs @@ -51,7 +51,41 @@ private void HandleFeatureFlagEvent(ActivityEvent activityEvent) foreach (var tag in activityEvent.Tags) { - properties[tag.Key] = tag.Value?.ToString(); + // FeatureEvaluation event schema: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureEvaluationEvent/FeatureEvaluationEvent.v1.0.0.schema.json + if (tag.Value is VariantAssignmentReason reason) + { + switch (reason) + { + case VariantAssignmentReason.None: + properties[tag.Key] = "None"; + break; + case VariantAssignmentReason.DefaultWhenDisabled: + properties[tag.Key] = "DefaultWhenDisabled"; + break; + case VariantAssignmentReason.DefaultWhenEnabled: + properties[tag.Key] = "DefaultWhenEnabled"; + break; + case VariantAssignmentReason.User: + properties[tag.Key] = "User"; + break; + case VariantAssignmentReason.Group: + properties[tag.Key] = "Group"; + break; + case VariantAssignmentReason.Percentile: + properties[tag.Key] = "Percentile"; + break; + default: + throw new ArgumentOutOfRangeException(nameof(activityEvent), "The variant assignment reason is unrecognizable."); + } + } + else if (tag.Value is bool val) + { + properties[tag.Key] = val ? "True" : "False"; + } + else + { + properties[tag.Key] = tag.Value?.ToString(); + } } _telemetryClient.TrackEvent("FeatureEvaluation", properties); diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj index 69f03a3d..85bfe628 100644 --- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj +++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj @@ -4,7 +4,7 @@ 4 - 0 + 1 0 diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 2591e667..91589852 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; +using System.Threading; namespace Microsoft.FeatureManagement { @@ -25,5 +26,10 @@ public class FeatureFilterEvaluationContext /// The settings are made available for s that implement . /// public object Settings { get; set; } + + /// + /// A cancellation token that can be used to request cancellation of the feature evaluation operation. + /// + public CancellationToken CancellationToken { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs index 5e8b6872..200910ca 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs @@ -347,7 +347,7 @@ private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) /// private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { - List result = daysOfWeek.ToList(); + List result = daysOfWeek.Distinct().ToList(); // dedup result.Sort((x, y) => CalculateWeeklyDayOffset(x, firstDayOfWeek) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs index 230ae674..54b31e16 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs @@ -341,49 +341,40 @@ private static bool TryValidateNumberOfOccurrences(TimeWindowFilterSettings sett private static bool IsDurationCompliantWithDaysOfWeek(TimeSpan duration, int interval, IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { Debug.Assert(interval > 0); + Debug.Assert(daysOfWeek.Count() > 0); if (daysOfWeek.Count() == 1) { return true; } - DateTime firstDayOfThisWeek = DateTime.Today.AddDays( - DaysPerWeek - CalculateWeeklyDayOffset(DateTime.Today.DayOfWeek, firstDayOfWeek)); - List sortedDaysOfWeek = SortDaysOfWeek(daysOfWeek, firstDayOfWeek); - DateTime prev = DateTime.MinValue; + DayOfWeek firstDay = sortedDaysOfWeek.First(); // the closest occurrence day to the first day of week + + DayOfWeek prev = firstDay; TimeSpan minGap = TimeSpan.FromDays(DaysPerWeek); - foreach (DayOfWeek dayOfWeek in sortedDaysOfWeek) + for (int i = 1; i < sortedDaysOfWeek.Count(); i++) // start from the second day to calculate the gap { - DateTime date = firstDayOfThisWeek.AddDays( - CalculateWeeklyDayOffset(dayOfWeek, firstDayOfWeek)); + DayOfWeek dayOfWeek = sortedDaysOfWeek[i]; - if (prev != DateTime.MinValue) - { - TimeSpan gap = date - prev; + TimeSpan gap = TimeSpan.FromDays(CalculateWeeklyDayOffset(dayOfWeek, prev)); - if (gap < minGap) - { - minGap = gap; - } + if (gap < minGap) + { + minGap = gap; } - prev = date; + prev = dayOfWeek; } // // It may across weeks. Check the next week if the interval is one week. if (interval == 1) { - DateTime firstDayOfNextWeek = firstDayOfThisWeek.AddDays(DaysPerWeek); - - DateTime firstOccurrenceInNextWeek = firstDayOfNextWeek.AddDays( - CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), firstDayOfWeek)); - - TimeSpan gap = firstOccurrenceInNextWeek - prev; + TimeSpan gap = TimeSpan.FromDays(CalculateWeeklyDayOffset(firstDay, prev)); if (gap < minGap) { @@ -413,7 +404,7 @@ private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) /// private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { - List result = daysOfWeek.ToList(); + List result = daysOfWeek.Distinct().ToList(); // dedup result.Sort((x, y) => CalculateWeeklyDayOffset(x, firstDayOfWeek) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index dfe794ec..00ff95a3 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -368,56 +368,13 @@ private async ValueTask EvaluateFeature(string featur Activity.Current != null && Activity.Current.IsAllDataRequested) { - AddEvaluationActivityEvent(evaluationEvent); + FeatureEvaluationTelemetry.Publish(evaluationEvent, Logger); } } return evaluationEvent; } - private void AddEvaluationActivityEvent(EvaluationEvent evaluationEvent) - { - Debug.Assert(evaluationEvent != null); - Debug.Assert(evaluationEvent.FeatureDefinition != null); - - var tags = new ActivityTagsCollection() - { - { "FeatureName", evaluationEvent.FeatureDefinition.Name }, - { "Enabled", evaluationEvent.Enabled }, - { "VariantAssignmentReason", evaluationEvent.VariantAssignmentReason }, - { "Version", ActivitySource.Version } - }; - - if (!string.IsNullOrEmpty(evaluationEvent.TargetingContext?.UserId)) - { - tags["TargetingId"] = evaluationEvent.TargetingContext.UserId; - } - - if (!string.IsNullOrEmpty(evaluationEvent.Variant?.Name)) - { - tags["Variant"] = evaluationEvent.Variant.Name; - } - - if (evaluationEvent.FeatureDefinition.Telemetry.Metadata != null) - { - foreach (KeyValuePair kvp in evaluationEvent.FeatureDefinition.Telemetry.Metadata) - { - if (tags.ContainsKey(kvp.Key)) - { - Logger?.LogWarning("{key} from telemetry metadata will be ignored, as it would override an existing key.", kvp.Key); - - continue; - } - - tags[kvp.Key] = kvp.Value; - } - } - - var activityEvent = new ActivityEvent("FeatureFlag", DateTimeOffset.UtcNow, tags); - - Activity.Current.AddEvent(activityEvent); - } - private async ValueTask IsEnabledAsync(FeatureDefinition featureDefinition, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { Debug.Assert(featureDefinition != null); @@ -472,6 +429,8 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature // For all enabling filters listed in the feature's state, evaluate them according to requirement type foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { + cancellationToken.ThrowIfCancellationRequested(); + filterIndex++; // @@ -524,7 +483,8 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature var context = new FeatureFilterEvaluationContext() { FeatureName = featureDefinition.Name, - Parameters = featureFilterConfiguration.Parameters + Parameters = featureFilterConfiguration.Parameters, + CancellationToken = cancellationToken }; BindSettings(filter, context, filterIndex); @@ -611,6 +571,8 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (UserAllocation user in evaluationEvent.FeatureDefinition.Allocation.User) { + cancellationToken.ThrowIfCancellationRequested(); + if (TargetingEvaluator.IsTargeted(targetingContext.UserId, user.Users, _assignerOptions.IgnoreCase)) { if (string.IsNullOrEmpty(user.Variant)) @@ -637,6 +599,8 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (GroupAllocation group in evaluationEvent.FeatureDefinition.Allocation.Group) { + cancellationToken.ThrowIfCancellationRequested(); + if (TargetingEvaluator.IsTargeted(targetingContext.Groups, group.Groups, _assignerOptions.IgnoreCase)) { if (string.IsNullOrEmpty(group.Variant)) @@ -663,6 +627,8 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (PercentileAllocation percentile in evaluationEvent.FeatureDefinition.Allocation.Percentile) { + cancellationToken.ThrowIfCancellationRequested(); + if (TargetingEvaluator.IsTargeted( targetingContext, percentile.From, @@ -795,7 +761,7 @@ private bool IsMatchingName(Type filterType, string filterName) // // Feature filters can have namespaces in their alias // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' - // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' + // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' if (filterName.Contains('.')) { // diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index dfd5ccc8..31a47510 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -5,7 +5,7 @@ 4 - 0 + 1 0 diff --git a/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs b/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs index bac65418..05c35f99 100644 --- a/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs +++ b/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs @@ -4,14 +4,14 @@ namespace Microsoft.FeatureManagement { - // - // Microsoft Feature Management schema: https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json + /// + /// See Microsoft Feature Management schema: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureManagement.v2.0.0.schema.json + /// internal static class MicrosoftFeatureManagementFields { public const string FeatureManagementSectionName = "feature_management"; public const string FeatureFlagsSectionName = "feature_flags"; - // // Microsoft feature flag keywords public const string Id = "id"; public const string Enabled = "enabled"; @@ -19,7 +19,6 @@ internal static class MicrosoftFeatureManagementFields public const string ClientFilters = "client_filters"; public const string RequirementType = "requirement_type"; - // // Allocation keywords public const string AllocationSectionName = "allocation"; public const string AllocationDefaultWhenDisabled = "default_when_disabled"; @@ -34,7 +33,6 @@ internal static class MicrosoftFeatureManagementFields public const string PercentileAllocationTo = "to"; public const string AllocationSeed = "seed"; - // // Client filter keywords public const string Name = "name"; public const string Parameters = "parameters"; diff --git a/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs new file mode 100644 index 00000000..26952ca5 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.FeatureManagement.Telemetry +{ + internal static class FeatureEvaluationTelemetry + { + private static readonly string EvaluationEventVersion = "1.0.0"; + + /// + /// Handles an evaluation event by adding it as an activity event to the current Activity. + /// + /// The to publish as an + /// Optional logger to log warnings to + public static void Publish(EvaluationEvent evaluationEvent, ILogger logger) + { + if (Activity.Current == null) + { + throw new InvalidOperationException("An Activity must be created before calling this method."); + } + + if (evaluationEvent == null) + { + throw new ArgumentNullException(nameof(evaluationEvent)); + } + + if (evaluationEvent.FeatureDefinition == null) + { + throw new ArgumentNullException(nameof(evaluationEvent.FeatureDefinition)); + } + + var tags = new ActivityTagsCollection() + { + { "FeatureName", evaluationEvent.FeatureDefinition.Name }, + { "Enabled", evaluationEvent.Enabled }, + { "VariantAssignmentReason", evaluationEvent.VariantAssignmentReason }, + { "Version", EvaluationEventVersion } + }; + + if (!string.IsNullOrEmpty(evaluationEvent.TargetingContext?.UserId)) + { + tags["TargetingId"] = evaluationEvent.TargetingContext.UserId; + } + + if (!string.IsNullOrEmpty(evaluationEvent.Variant?.Name)) + { + tags["Variant"] = evaluationEvent.Variant.Name; + } + + if (evaluationEvent.FeatureDefinition.Telemetry.Metadata != null) + { + foreach (KeyValuePair kvp in evaluationEvent.FeatureDefinition.Telemetry.Metadata) + { + if (tags.ContainsKey(kvp.Key)) + { + logger?.LogWarning($"{kvp.Key} from telemetry metadata will be ignored, as it would override an existing key."); + + continue; + } + + tags[kvp.Key] = kvp.Value; + } + } + + // VariantAssignmentPercentage + if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.DefaultWhenEnabled) + { + // If the variant was assigned due to DefaultWhenEnabled, the percentage reflects the unallocated percentiles + double allocatedPercentage = evaluationEvent.FeatureDefinition.Allocation?.Percentile?.Sum(p => p.To - p.From) ?? 0; + + tags["VariantAssignmentPercentage"] = 100 - allocatedPercentage; + } + else if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.Percentile) + { + // If the variant was assigned due to Percentile, the percentage is the sum of the allocated percentiles for the given variant + if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) + { + tags["VariantAssignmentPercentage"] = evaluationEvent.FeatureDefinition.Allocation.Percentile + .Where(p => p.Variant == evaluationEvent.Variant?.Name) + .Sum(p => p.To - p.From); + } + } + + // DefaultWhenEnabled + if (evaluationEvent.FeatureDefinition.Allocation?.DefaultWhenEnabled != null) + { + tags["DefaultWhenEnabled"] = evaluationEvent.FeatureDefinition.Allocation.DefaultWhenEnabled; + } + + var activityEvent = new ActivityEvent("FeatureFlag", DateTimeOffset.UtcNow, tags); + + Activity.Current.AddEvent(activityEvent); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs new file mode 100644 index 00000000..91f4370b --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs @@ -0,0 +1,463 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.AspNetCore; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.FeatureManagement.AspNetCore +{ + public class TestTargetingContextAccessor : ITargetingContextAccessor + { + private readonly string _userId; + private readonly string[] _groups; + + public TestTargetingContextAccessor(string userId = "testUser", string[] groups = null) + { + _userId = userId; + _groups = groups ?? new[] { "testGroup" }; + } + + public ValueTask GetContextAsync() + { + var context = new TargetingContext + { + UserId = _userId, + Groups = _groups + }; + return new ValueTask(context); + } + } + + public class FeatureTestServer : IDisposable + { + private readonly IHost _host; + private readonly HttpClient _client; + private readonly IDictionary _featureSettings; + private readonly ITargetingContextAccessor _targetingContextAccessor; + private readonly Action _endpointConfiguration; + + public FeatureTestServer( + IDictionary featureSettings = null, + ITargetingContextAccessor targetingContextAccessor = null, + Action endpointConfiguration = null) + { + _featureSettings = featureSettings ?? new Dictionary + { + ["FeatureManagement:TestFeature"] = "true" + }; + _targetingContextAccessor = targetingContextAccessor ?? new TestTargetingContextAccessor(); + _endpointConfiguration = endpointConfiguration ?? DefaultEndpointConfiguration; + _host = CreateHostBuilder().Build(); + _host.Start(); + _client = _host.GetTestServer().CreateClient(); + } + + private void DefaultEndpointConfiguration(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/test", new Func(() => "Feature Enabled")) + .WithFeatureGate("TestFeature"); + endpoints.MapGet("/test-targeting", new Func(() => "Feature With Targeting Enabled")) + .WithFeatureGate("TestFeatureWithTargeting"); + } + + private IHostBuilder CreateHostBuilder() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(_featureSettings) + .Build(); + + return Host.CreateDefaultBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(configuration); + services.AddSingleton(_targetingContextAccessor); + services.AddFeatureManagement() + .AddFeatureFilter(); + services.AddRouting(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseMiddleware(); + app.UseEndpoints(_endpointConfiguration); + }); + }); + } + + public HttpClient Client => _client; + + public void Dispose() + { + _host?.Dispose(); + _client?.Dispose(); + } + } + + public class FeatureGateEndpointFilterTests + { + [Fact] + public async Task WhenFeatureEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "true" + }; + + using var server = new FeatureTestServer(featureSettings: settings); + var response = await server.Client.GetAsync("/test"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenFeatureDisabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer(featureSettings: settings); + var response = await server.Client.GetAsync("/test"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenMultipleFeatures_AllEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "true" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-multiple", new Func(() => "Multiple Features Enabled")) + .WithFeatureGate("TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-multiple"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenMultipleFeatures_OneDisabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-multiple", new Func(() => "Multiple Features Enabled")) + .WithFeatureGate("TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-multiple"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenRequirementTypeAny_OneEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-any", new Func(() => "Any Feature Enabled")) + .WithFeatureGate(RequirementType.Any, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-any"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenRequirementTypeAny_AllDisabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "false", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-any", new Func(() => "Any Feature Enabled")) + .WithFeatureGate(RequirementType.Any, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-any"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenNegated_FeatureDisabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated", new Func(() => "Negated Feature")) + .WithFeatureGate(true, "TestFeature"); + }); + + var response = await server.Client.GetAsync("/test-negated"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenNegated_FeatureEnabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated", new Func(() => "Negated Feature")) + .WithFeatureGate(RequirementType.All, true, "TestFeature"); + }); + + var response = await server.Client.GetAsync("/test-negated"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenNegatedWithMultipleFeatures_AllDisabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "false", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated-multiple", new Func(() => "Negated Multiple Features")) + .WithFeatureGate(RequirementType.All, true, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-negated-multiple"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenTargetingEnabled_AndUserInTarget_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:Name"] = "targetGroup", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:RolloutPercentage"] = "100", + }; + + var targetingAccessor = new TestTargetingContextAccessor( + userId: "targetUser", + groups: new[] { "targetGroup" } + ); + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor + ); + var response = await server.Client.GetAsync("/test-targeting"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenTargetingEnabled_AndUserNotInTarget_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:Name"] = "targetGroup", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:RolloutPercentage"] = "100", + }; + + var targetingAccessor = new TestTargetingContextAccessor( + userId: "nonTargetUser", + groups: new[] { "nonTargetGroup" } + ); + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor + ); + var response = await server.Client.GetAsync("/test-targeting"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenGroupFeatureEnabled_AllEndpointsInGroup_ReturnSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupFeature"] = "true" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupFeature"); + group.MapGet("/endpoint1", new Func(() => "Endpoint 1")); + group.MapGet("/endpoint2", new Func(() => "Endpoint 2")); + }); + + var response1 = await server.Client.GetAsync("/api/endpoint1"); + var response2 = await server.Client.GetAsync("/api/endpoint2"); + + Assert.Equal(System.Net.HttpStatusCode.OK, response1.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.OK, response2.StatusCode); + } + + [Fact] + public async Task WhenGroupFeatureDisabled_AllEndpointsInGroup_ReturnNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupFeature"); + + group.MapGet("/endpoint1", new Func(() => "Endpoint 1")); + group.MapGet("/endpoint2", new Func(() => "Endpoint 2")); + }); + + var response1 = await server.Client.GetAsync("/api/endpoint1"); + var response2 = await server.Client.GetAsync("/api/endpoint2"); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, response1.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response2.StatusCode); + } + + [Fact] + public async Task WhenNestedGroups_WithMultipleFeatures_ReturnsExpectedResults() + { + var settings = new Dictionary + { + ["FeatureManagement:ParentFeature"] = "true", + ["FeatureManagement:ChildFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var parentGroup = endpoints.MapGroup("/parent"); + parentGroup.WithFeatureGate("ParentFeature"); + + var childGroup = parentGroup.MapGroup("/child"); + childGroup.WithFeatureGate("ChildFeature"); + + parentGroup.MapGet("/endpoint", new Func(() => "Parent Endpoint")); + childGroup.MapGet("/endpoint", new Func(() => "Child Endpoint")); + }); + + var parentResponse = await server.Client.GetAsync("/parent/endpoint"); + var childResponse = await server.Client.GetAsync("/parent/child/endpoint"); + + Assert.Equal(System.Net.HttpStatusCode.OK, parentResponse.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, childResponse.StatusCode); + } + + [Fact] + public async Task WhenGroupWithRequirementTypeAny_OneFeatureEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:Feature1"] = "true", + ["FeatureManagement:Feature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate(RequirementType.Any, "Feature1", "Feature2"); + + group.MapGet("/endpoint", new Func(() => "Any Feature Endpoint")); + }); + + var response = await server.Client.GetAsync("/api/endpoint"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenGroupWithTargeting_AndUserInTarget_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupTargetFeature:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:GroupTargetFeature:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser" + }; + + var targetingAccessor = new TestTargetingContextAccessor(userId: "targetUser"); + + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupTargetFeature"); + + group.MapGet("/targeted", new Func(() => "Targeted Endpoint")); + }); + + var response = await server.Client.GetAsync("/api/targeted"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs index 79352588..68b7efc1 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs @@ -97,9 +97,13 @@ public async Task GatesFeatures() HttpResponseMessage gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); HttpResponseMessage gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + HttpResponseMessage gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + HttpResponseMessage gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.OK, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable 1/2 features @@ -107,9 +111,13 @@ public async Task GatesFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable no @@ -117,9 +125,13 @@ public async Task GatesFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode); } [Fact] @@ -153,9 +165,13 @@ public async Task GatesRazorPageFeatures() HttpResponseMessage gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); HttpResponseMessage gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + HttpResponseMessage gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + HttpResponseMessage gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.OK, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable 1/2 features @@ -163,9 +179,13 @@ public async Task GatesRazorPageFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable no @@ -173,9 +193,13 @@ public async Task GatesRazorPageFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode); } private static void DisableEndpointRouting(MvcOptions options) diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml new file mode 100644 index 00000000..a4e91e45 --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml @@ -0,0 +1,2 @@ +@page +@model RazorTestAllNegateModel diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs new file mode 100644 index 00000000..ba9aff1c --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.FeatureManagement.Mvc; + +namespace Tests.FeatureManagement.AspNetCore.Pages +{ + [FeatureGate(negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public class RazorTestAllNegateModel : PageModel + { + public IActionResult OnGet() + { + return new OkResult(); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml new file mode 100644 index 00000000..d232c3fe --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml @@ -0,0 +1,4 @@ +@page +@model Tests.FeatureManagement.AspNetCore.Pages.RazorTestAnyNegateModel +@{ +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs new file mode 100644 index 00000000..09821913 --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Mvc; + +namespace Tests.FeatureManagement.AspNetCore.Pages +{ + [FeatureGate(requirementType: RequirementType.Any, negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public class RazorTestAnyNegateModel : PageModel + { + public IActionResult OnGet() + { + return new OkResult(); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/TestController.cs b/tests/Tests.FeatureManagement.AspNetCore/TestController.cs index 2f4c8ce5..6fc000a5 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/TestController.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/TestController.cs @@ -26,10 +26,26 @@ public IActionResult GateAll() [Route("/gateAny")] [HttpGet] - [FeatureGate(RequirementType.Any, Features.ConditionalFeature, Features.ConditionalFeature2)] + [FeatureGate(requirementType: RequirementType.Any, Features.ConditionalFeature, Features.ConditionalFeature2)] public IActionResult GateAny() { return Ok(); } + + [Route("/gateAllNegate")] + [HttpGet] + [FeatureGate(negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public IActionResult GateAllNegate() + { + return Ok(); + } + + [Route("/gateAnyNegate")] + [HttpGet] + [FeatureGate(requirementType: RequirementType.Any, negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public IActionResult GateAnyNegate() + { + return Ok(); + } } } diff --git a/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj b/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj index 8557c30c..e91b1be6 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj +++ b/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj @@ -1,7 +1,7 @@ - net6.0;net8.0;net9.0 + net8.0;net9.0 false 8.0 True @@ -20,13 +20,6 @@ - - - - - - - diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 26c083db..1a3b6c5e 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -849,6 +849,13 @@ public async Task TimeWindow() Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:Type", "Weekly"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:0", "Monday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:1", "Tuesday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:2", "Wednesday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:3", "Thursday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:4", "Friday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:5", "Saturday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:6", "Sunday"); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Range:Type", "NoEnd"); foreach (DayOfWeek day in Enum.GetValues(typeof(DayOfWeek))) @@ -874,11 +881,7 @@ public async Task TimeWindow() Assert.False(await featureManager.IsEnabledAsync(feature4)); Assert.True(await featureManager.IsEnabledAsync(feature5)); Assert.False(await featureManager.IsEnabledAsync(feature6)); - - for (int i = 0; i < 10; i++) - { - Assert.True(await featureManager.IsEnabledAsync(feature7)); - } + Assert.True(await featureManager.IsEnabledAsync(feature7)); } [Fact] @@ -1703,6 +1706,9 @@ public async Task TelemetryPublishing() string label = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "Label").Value?.ToString(); string firstTag = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "Tags.Tag1").Value?.ToString(); + string variantAssignmentPercentage = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "VariantAssignmentPercentage").Value?.ToString(); + string defaultWhenEnabled = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "DefaultWhenEnabled").Value?.ToString(); + // Test telemetry cases switch (featureName) { @@ -1730,6 +1736,8 @@ public async Task TelemetryPublishing() Assert.Equal("True", enabled); Assert.Equal("Medium", variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Equal("Medium", defaultWhenEnabled); break; case Features.VariantFeatureDefaultDisabled: @@ -1738,6 +1746,8 @@ public async Task TelemetryPublishing() Assert.Equal("False", enabled); Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeaturePercentileOn: @@ -1760,6 +1770,8 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeatureUser: @@ -1767,6 +1779,8 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.User.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeatureGroup: @@ -1774,6 +1788,8 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.Group.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeatureNoVariants: @@ -1781,6 +1797,8 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.None.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeatureNoAllocation: @@ -1788,6 +1806,8 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); break; case Features.VariantFeatureAlwaysOffNoAllocation: @@ -1795,6 +1815,17 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + break; + + case Features.VariantFeatureIncorrectDefaultWhenEnabled: + Assert.Equal(13, currentTest); + currentTest = 0; + Assert.Null(variantName); + Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Equal("Foo", defaultWhenEnabled); break; default: @@ -1859,6 +1890,10 @@ public async Task TelemetryPublishing() await featureManager.GetVariantAsync(Features.VariantFeatureAlwaysOffNoAllocation, cancellationToken); Assert.Equal(0, currentTest); + currentTest = 13; + await featureManager.GetVariantAsync(Features.VariantFeatureIncorrectDefaultWhenEnabled, cancellationToken); + Assert.Equal(0, currentTest); + // Test a feature with telemetry disabled- should throw if the listener hits it bool result = await featureManager.IsEnabledAsync(Features.OnTestFeature, cancellationToken); diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index e0746fef..99baf728 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -23,6 +23,7 @@ static class Features public const string VariantFeatureGroup = "VariantFeatureGroup"; public const string VariantFeatureNoVariants = "VariantFeatureNoVariants"; public const string VariantFeatureNoAllocation = "VariantFeatureNoAllocation"; + public const string VariantFeatureIncorrectDefaultWhenEnabled = "VariantFeatureIncorrectDefaultWhenEnabled"; public const string VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation"; public const string VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride"; public const string VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo"; diff --git a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs index c65c1abb..498abf8a 100644 --- a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs +++ b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs @@ -291,6 +291,7 @@ public void InvalidTimeWindowAcrossWeeksTest() { Type = RecurrencePatternType.Weekly, Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, DaysOfWeek = new List() { DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. }, Range = new RecurrenceRange() @@ -299,7 +300,7 @@ public void InvalidTimeWindowAcrossWeeksTest() // // The settings is valid. No exception should be thrown. - RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); settings = new TimeWindowFilterSettings() { @@ -320,7 +321,49 @@ public void InvalidTimeWindowAcrossWeeksTest() // // The settings is valid. No exception should be thrown. - RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-17T00:00:00+08:00"), // Time window duration is 2 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Saturday } + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-17T00:00:01+08:00"), // Time window duration is more than 2 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Saturday } + }, + Range = new RecurrenceRange() + } + }; + + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + Assert.Equal(ParamName.End, paramName); + Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); settings = new TimeWindowFilterSettings() { @@ -339,7 +382,7 @@ public void InvalidTimeWindowAcrossWeeksTest() } }; - Assert.False(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); Assert.Equal(ParamName.End, paramName); Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); } @@ -1617,17 +1660,17 @@ public void FindWeeklyClosestStartTest() [Fact] public async Task RecurrenceEvaluationThroughCacheTest() { - OnDemandClock mockedTimeProvider = new OnDemandClock(); + var mockedTimeProvider = new OnDemandClock(); - var mockedTimeWindowFilter = new TimeWindowFilter() + using (var cache = new TestCache()) { - Cache = new MemoryCache(new MemoryCacheOptions()), - SystemClock = mockedTimeProvider - }; + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = cache, + SystemClock = mockedTimeProvider + }; - var context = new FeatureFilterEvaluationContext() - { - Settings = new TimeWindowFilterSettings() + TimeWindowFilterSettings settings = new TimeWindowFilterSettings() { Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), @@ -1644,43 +1687,80 @@ public async Task RecurrenceEvaluationThroughCacheTest() EndDate = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00") } } - } - }; + }; - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + var context = new FeatureFilterEvaluationContext() + { + Settings = settings + }; - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + DateTimeOffset? closestStart; + Assert.False(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(0, cache.CountOfEntryCreation); - for (int i = 0; i < 12; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); - } + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); + } - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"), closestStart); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); - for (int i = 0; i < 10; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + } } - context = new FeatureFilterEvaluationContext() + using (var cache = new TestCache()) { - Settings = new TimeWindowFilterSettings() + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = cache, + SystemClock = mockedTimeProvider + }; + + var settings = new TimeWindowFilterSettings() { Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), @@ -1698,43 +1778,82 @@ public async Task RecurrenceEvaluationThroughCacheTest() NumberOfOccurrences = 2 } } - } - }; + }; - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + var context = new FeatureFilterEvaluationContext() + { + Settings = settings + }; - for (int i = 0; i < 12; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); - } + DateTimeOffset? closestStart; + Assert.False(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(0, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); + } - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - for (int i = 0; i < 10; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + } } } } diff --git a/tests/Tests.FeatureManagement/TestCache.cs b/tests/Tests.FeatureManagement/TestCache.cs new file mode 100644 index 00000000..39a02655 --- /dev/null +++ b/tests/Tests.FeatureManagement/TestCache.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Caching.Memory; + +namespace Tests.FeatureManagement +{ + class TestCache : IMemoryCache + { + private readonly IMemoryCache _cache; + private int _countOfEntryCreation; + + public TestCache() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + public int CountOfEntryCreation + { + get => _countOfEntryCreation; + } + + public bool TryGetValue(object key, out object value) + { + return _cache.TryGetValue(key, out value); + } + + public ICacheEntry CreateEntry(object key) + { + _countOfEntryCreation += 1; + + return _cache.CreateEntry(key); + } + + public void Remove(object key) + { + _cache.Remove(key); + } + + public void Dispose() + { + _cache.Dispose(); + } + } +} diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 3eee5035..4a915a4f 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -1,7 +1,7 @@ - net48;net6.0;net8.0;net9.0 + net48;net8.0;net9.0 false 8.0 True @@ -20,13 +20,6 @@ - - - - - - - diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index b1ad1e72..e192a5ff 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -209,7 +209,7 @@ "to": 50 } ], - "seed": 1234 + "seed": "1234" }, "telemetry": { "enabled": true @@ -231,7 +231,7 @@ "to": 50 } ], - "seed": 12345 + "seed": "12345" }, "telemetry": { "enabled": true @@ -253,7 +253,7 @@ "to": 100 } ], - "seed": 12345 + "seed": "12345" }, "telemetry": { "enabled": true @@ -383,6 +383,22 @@ "enabled": true } }, + { + "id": "VariantFeatureIncorrectDefaultWhenEnabled", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Foo" + }, + "telemetry": { + "enabled": true + } + }, { "id": "VariantFeatureAlwaysOffNoAllocation", "enabled": false,