diff --git a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs index 49d1e87..3cd99bc 100644 --- a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs +++ b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs @@ -1,7 +1,21 @@ +using Bitwarden.Extensions.Hosting.Features; + var builder = WebApplication.CreateBuilder(args); builder.UseBitwardenDefaults(); var app = builder.Build(); +app.UseRouting(); + +app.UseFeatureFlagChecks(); + +app.MapGet("/", (IConfiguration config) => ((IConfigurationRoot)config).GetDebugView()); + +app.MapGet("/requires-feature", (IFeatureService featureService) => +{ + return featureService.GetAll(); +}) + .RequireFeature("feature-one"); + app.Run(); diff --git a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json index 0c208ae..28d6b86 100644 --- a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json +++ b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json @@ -4,5 +4,13 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Features": { + "FlagValues": { + "feature-one": true, + "feature-two": 1, + "feature-three": "my-value" + }, + "KnownFlags": ["feature-one", "feature-two"] } } diff --git a/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs b/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs deleted file mode 100644 index aa78691..0000000 --- a/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Reflection; - -namespace Bitwarden.Extensions.Hosting; - -/// -/// Helper class for working with assembly attributes. -/// -public static class AssemblyHelpers -{ - private const string _gitHashAssemblyKey = "GitHash"; - - private static readonly IEnumerable _assemblyMetadataAttributes; - private static readonly AssemblyInformationalVersionAttribute? _assemblyInformationalVersionAttributes; - private static string? _version; - private static string? _gitHash; - - static AssemblyHelpers() - { - _assemblyMetadataAttributes = Assembly.GetEntryAssembly()! - .GetCustomAttributes(); - _assemblyInformationalVersionAttributes = Assembly.GetEntryAssembly()! - .GetCustomAttribute(); - } - - /// - /// Gets the version of the entry assembly. - /// - /// - public static string? GetVersion() - { - if (string.IsNullOrWhiteSpace(_version)) - { - _version = _assemblyInformationalVersionAttributes?.InformationalVersion; - } - - return _version; - } - - /// - /// Gets the Git hash of the entry assembly. - /// - /// - public static string? GetGitHash() - { - if (string.IsNullOrWhiteSpace(_gitHash)) - { - try - { - _gitHash = _assemblyMetadataAttributes.First(i => - i.Key == _gitHashAssemblyKey).Value; - } - catch (Exception) - { - // suppress - } - } - - return _gitHash; - } -} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs index a2f3da2..2fc9f0e 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs @@ -1,4 +1,4 @@ -using Bitwarden.Extensions.Hosting.Exceptions; +using Bitwarden.Extensions.Hosting.Exceptions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj index 1adc280..6980999 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj +++ b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj @@ -22,6 +22,7 @@ + @@ -43,5 +44,9 @@ - + + + + + diff --git a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs index cad3f1b..c844be6 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs @@ -1,4 +1,4 @@ -namespace Bitwarden.Extensions.Hosting; +namespace Bitwarden.Extensions.Hosting; /// /// Options for configuring the Bitwarden host. diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs new file mode 100644 index 0000000..30c8ac6 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs @@ -0,0 +1,29 @@ +using Bitwarden.Extensions.Hosting.Features; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Feature extension methods for . +/// +public static class FeatureApplicationBuilderExtensions +{ + /// + /// Adds the to the specified , which enabled feature check capabilities. + /// + /// This call must take place between app.UseRouting() and app.UseEndpoints(...) for middleware to function properly. + /// + /// + /// The to add the middleware to. + /// A reference to after the operation has completed. + public static IApplicationBuilder UseFeatureFlagChecks(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + // This would be a good time to make sure that IFeatureService is registered but it is a scoped service + // and I don't think creating a scope is worth it for that. If we think this is a problem we can add another + // marker interface that is registered as a singleton and validate that it exists here. + + app.UseMiddleware(); + return app; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs new file mode 100644 index 0000000..b5e7336 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bitwarden.Extensions.Hosting.Features; + +internal sealed class FeatureCheckMiddleware +{ + private readonly RequestDelegate _next; + private readonly IHostEnvironment _hostEnvironment; + private readonly IProblemDetailsService _problemDetailsService; + private readonly ILogger _logger; + + public FeatureCheckMiddleware( + RequestDelegate next, + IHostEnvironment hostEnvironment, + IProblemDetailsService problemDetailsService, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(hostEnvironment); + ArgumentNullException.ThrowIfNull(problemDetailsService); + ArgumentNullException.ThrowIfNull(logger); + + _next = next; + _hostEnvironment = hostEnvironment; + _problemDetailsService = problemDetailsService; + _logger = logger; + } + + public Task Invoke(HttpContext context, IFeatureService featureService) + { + // This middleware is expected to be placed after `UseRouting()` which will fill in this endpoint + var endpoint = context.GetEndpoint(); + + if (endpoint == null) + { + _logger.LogNoEndpointWarning(); + return _next(context); + } + + var featureMetadatas = endpoint.Metadata.GetOrderedMetadata(); + + foreach (var featureMetadata in featureMetadatas) + { + if (!featureMetadata.FeatureCheck(featureService)) + { + // Do not execute more of the pipeline, return early. + return HandleFailedFeatureCheck(context, featureMetadata); + } + + // Continue checking + } + + // Either there were no feature checks, or none were failed. Continue on in the pipeline. + return _next(context); + } + + private async Task HandleFailedFeatureCheck(HttpContext context, IFeatureMetadata failedFeature) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogFailedFeatureCheck(failedFeature.ToString()!); + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + + var problemDetails = new ProblemDetails(); + problemDetails.Title = "Resource not found."; + problemDetails.Status = StatusCodes.Status404NotFound; + + // Message added for legacy reasons. We should start preferring title/detail + problemDetails.Extensions["Message"] = "Resource not found."; + + // Follow ProblemDetails output type? Would need clients update + if (_hostEnvironment.IsDevelopment()) + { + // Add extra information + problemDetails.Detail = $"Feature check failed: {failedFeature}"; + } + + // We don't really care if this fails, we will return the 404 no matter what. + await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = context, + ProblemDetails = problemDetails, + // TODO: Add metadata? + }); + } + + +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs new file mode 100644 index 0000000..bdfa620 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs @@ -0,0 +1,49 @@ +using Bitwarden.Extensions.Hosting.Features; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Feature extension methods for . +/// +public static class FeatureEndpointConventionBuilderExtensions +{ + /// + /// Adds a feature check for the given feature to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// + public static TBuilder RequireFeature(this TBuilder builder, string featureNameKey) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(featureNameKey); + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(new RequireFeatureAttribute(featureNameKey)); + }); + + return builder; + } + + /// + /// Adds a feature check with the specified check to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// + public static TBuilder RequireFeature(this TBuilder builder, Func featureCheck) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(featureCheck); + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(new RequireFeatureAttribute(featureCheck)); + }); + + return builder; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs new file mode 100644 index 0000000..1caf152 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs @@ -0,0 +1,33 @@ +namespace Bitwarden.Extensions.Hosting.Features; + +/// +/// A collection of Launch Darkly specific options. +/// +public sealed class LaunchDarklyOptions +{ + /// + /// The SdkKey to be used for retrieving feature flag values from Launch Darkly. + /// + public string? SdkKey { get; set; } +} + +/// +/// A set of options for features. +/// +public sealed class FeatureFlagOptions +{ + /// + /// All the flags known to this instance, this is used to enumerable values in . + /// + public HashSet KnownFlags { get; set; } = new HashSet(); + + /// + /// Flags and flag values to include in the feature flag data source. + /// + public Dictionary FlagValues { get; set; } = new Dictionary(); + + /// + /// Launch Darkly specific options. + /// + public LaunchDarklyOptions LaunchDarkly { get; set; } = new LaunchDarklyOptions(); +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs new file mode 100644 index 0000000..b617b1d --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +using Bitwarden.Extensions.Hosting.Features; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for features on . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds known feature flags to the . This makes these flags + /// show up in . + /// + /// The service collection to customize the options on. + /// The flags to add to the known flags list. + /// The to chain additional calls. + public static IServiceCollection AddKnownFeatureFlags(this IServiceCollection services, IEnumerable knownFlags) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(options => + { + foreach (var flag in knownFlags) + { + options.KnownFlags.Add(flag); + } + }); + + return services; + } + + /// + /// Adds feature flags and their values to the . + /// + /// The service collection to customize the options on. + /// The flags to add to the flag values dictionary. + /// The to chain additional calls. + public static IServiceCollection AddFeatureFlagValues( + this IServiceCollection services, + IEnumerable> flagValues) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(options => + { + foreach (var flag in flagValues) + { + options.FlagValues[flag.Key] = flag.Value; + } + }); + + return services; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs new file mode 100644 index 0000000..693b0fc --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs @@ -0,0 +1,9 @@ +namespace Bitwarden.Extensions.Hosting.Features; + +internal interface IFeatureMetadata +{ + /// + /// A method to run to check if the feature is enabled. + /// + Func FeatureCheck { get; set; } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs new file mode 100644 index 0000000..c0961a3 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Nodes; + +namespace Bitwarden.Extensions.Hosting.Features; + +/// +/// Checks feature status for the current request. +/// +public interface IFeatureService +{ + /// + /// Checks whether a given feature is enabled. + /// + /// The key of the feature to check. + /// The default value for the feature. + /// True if the feature is enabled, otherwise false. + bool IsEnabled(string key, bool defaultValue = false); + + /// + /// Gets the integer variation of a feature. + /// + /// The key of the feature to check. + /// The default value for the feature. + /// The feature variation value. + int GetIntVariation(string key, int defaultValue = 0); + + /// + /// Gets the string variation of a feature. + /// + /// The key of the feature to check. + /// The default value for the feature. + /// The feature variation value. + string? GetStringVariation(string key, string? defaultValue = null); + + /// + /// Gets all feature values. + /// + /// A dictionary of feature keys and their values. + IReadOnlyDictionary GetAll(); +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs new file mode 100644 index 0000000..dac588e --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs @@ -0,0 +1,234 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using System.Text.Json.Nodes; +using Bitwarden.Extensions.Hosting.Utilities; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +using LaunchDarkly.Sdk.Server.Integrations; +using LaunchDarkly.Sdk.Server.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bitwarden.Extensions.Hosting.Features; + +internal sealed class LaunchDarklyFeatureService : IFeatureService +{ + const string AnonymousUser = "25a15cac-58cf-4ac0-ad0f-b17c4bd92294"; + + private readonly ILdClient _ldClient; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptionsMonitor _featureFlagOptions; + private readonly ILogger _logger; + + // Should not change during the course of a request, so cache this + private Context? _context; + + public LaunchDarklyFeatureService( + ILdClient ldClient, + IHttpContextAccessor httpContextAccessor, + IOptionsMonitor featureFlagOptions, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(ldClient); + ArgumentNullException.ThrowIfNull(httpContextAccessor); + ArgumentNullException.ThrowIfNull(featureFlagOptions); + ArgumentNullException.ThrowIfNull(logger); + + _ldClient = ldClient; + _httpContextAccessor = httpContextAccessor; + _featureFlagOptions = featureFlagOptions; + _logger = logger; + } + + public bool IsEnabled(string key, bool defaultValue = false) + { + return _ldClient.BoolVariation(key, GetContext(), defaultValue); + } + + public int GetIntVariation(string key, int defaultValue = 0) + { + return _ldClient.IntVariation(key, GetContext(), defaultValue); + } + + public string GetStringVariation(string key, string? defaultValue = null) + { + return _ldClient.StringVariation(key, GetContext(), defaultValue); + } + + public IReadOnlyDictionary GetAll() + { + var flagsState = _ldClient.AllFlagsState(GetContext()); + + var flagValues = new Dictionary(); + + if (!flagsState.Valid) + { + return flagValues; + } + + foreach (var knownFlag in _featureFlagOptions.CurrentValue.KnownFlags) + { + var ldValue = flagsState.GetFlagValueJson(knownFlag); + + switch (ldValue.Type) + { + case LdValueType.Bool: + flagValues.Add(knownFlag, (JsonValue)ldValue.AsBool); + break; + case LdValueType.Number: + flagValues.Add(knownFlag, (JsonValue)ldValue.AsInt); + break; + case LdValueType.String: + flagValues.Add(knownFlag, (JsonValue)ldValue.AsString); + break; + } + } + + return flagValues; + } + + private Context GetContext() + { + return _context ??= BuildContext(); + } + + private Context BuildContext() + { + static void AddCommon(ContextBuilder contextBuilder, HttpContext httpContext) + { + if (httpContext.Request.Headers.TryGetValue("bitwarden-client-version", out var clientVersion)) + { + contextBuilder.Set("client-version", clientVersion); + } + + if (httpContext.Request.Headers.TryGetValue("device-type", out var deviceType)) + { + contextBuilder.Set("device-type", deviceType); + } + } + + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + _logger.LogMissingHttpContext(); + return Context.Builder(AnonymousUser) + .Kind(ContextKind.Default) + .Anonymous(true) + .Build(); + } + + // TODO: We need to start enforcing this + var subject = httpContext.User.FindFirstValue("sub"); + + if (string.IsNullOrEmpty(subject)) + { + // Anonymous but with common headers + var anon = Context.Builder(AnonymousUser) + .Kind(ContextKind.Default) + .Anonymous(true); + + AddCommon(anon, httpContext); + return anon.Build(); + } + + // TODO: Need to start enforcing this + var organizations = httpContext.User.FindAll("organization"); + + var contextBuilder = Context.Builder(subject) + .Kind(ContextKind.Default) // TODO: This is not right + .Set("organizations", LdValue.ArrayFrom(organizations.Select(c => LdValue.Of(c.Value)))); + + AddCommon(contextBuilder, httpContext); + + return contextBuilder.Build(); + } +} + + +internal sealed class LaunchDarklyClientProvider +{ + private readonly ILoggerFactory _loggerFactory; + private readonly IHostEnvironment _hostEnvironment; + private readonly VersionInfo _versionInfo; + + private LdClient _client; + + public LaunchDarklyClientProvider( + IOptionsMonitor featureFlagOptions, + ILoggerFactory loggerFactory, + IHostEnvironment hostEnvironment) + { + _loggerFactory = loggerFactory; + _hostEnvironment = hostEnvironment; + + var versionInfo = _hostEnvironment.GetVersionInfo(); + + if (versionInfo == null) + { + throw new InvalidOperationException("Unable to attain version information for the current application."); + } + + _versionInfo = versionInfo; + + BuildClient(featureFlagOptions.CurrentValue); + // Subscribe to options changes. + featureFlagOptions.OnChange(BuildClient); + } + + [MemberNotNull(nameof(_client))] + private void BuildClient(FeatureFlagOptions featureFlagOptions) + { + var builder = Configuration.Builder(featureFlagOptions.LaunchDarkly.SdkKey) + .Logging(Components.Logging().Adapter(Logs.CoreLogging(_loggerFactory))) + .ApplicationInfo(Components.ApplicationInfo() + .ApplicationId(_hostEnvironment.ApplicationName) + .ApplicationName(_hostEnvironment.ApplicationName) + .ApplicationVersion(_versionInfo.GitHash ?? _versionInfo.Version.ToString()) + .ApplicationVersionName(_versionInfo.Version.ToString()) + ) + .DataSource(BuildDataSource(featureFlagOptions.FlagValues)) + .Events(Components.NoEvents); + + _client?.Dispose(); + _client = new LdClient(builder.Build()); + } + + private TestData BuildDataSource(Dictionary data) + { + // TODO: We could support updating just the test data source with + // changes from the OnChange of options, we currently support it through creating + // a whole new client but that could be pretty heavy just for flag + // value changes. + var source = TestData.DataSource(); + + foreach (var (key, value) in data) + { + var flag = source.Flag(key); + var valueSpan = value.AsSpan(); + if (bool.TryParse(valueSpan, out var boolValue)) + { + flag.ValueForAll(LdValue.Of(boolValue)); + } + else if (int.TryParse(valueSpan, out var intValue)) + { + flag.ValueForAll(LdValue.Of(intValue)); + } + else + { + flag.ValueForAll(LdValue.Of(value)); + } + + source.Update(flag); + } + + return source; + } + + public LdClient Get() + { + return _client; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs new file mode 100644 index 0000000..9314ae0 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace Bitwarden.Extensions.Hosting.Features; + +internal static partial class LoggerExtensions +{ + [LoggerMessage(1, LogLevel.Warning, "No endpoint set -- did you forget to call 'UseRouting()'?")] + public static partial void LogNoEndpointWarning(this ILogger logger); + + [LoggerMessage(2, LogLevel.Debug, "Failed feature check {CheckName}", SkipEnabledCheck = true)] + public static partial void LogFailedFeatureCheck(this ILogger logger, string checkName); + + [LoggerMessage(3, LogLevel.Warning, "No HttpContext available for the current feature flag check.")] + public static partial void LogMissingHttpContext(this ILogger logger); +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs new file mode 100644 index 0000000..af0102f --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs @@ -0,0 +1,39 @@ +namespace Bitwarden.Extensions.Hosting.Features; + +/// +/// Specifies that the class or method that this attribute is applied to requires a feature check to run. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireFeatureAttribute : Attribute, IFeatureMetadata +{ + private readonly string _stringRepresentation; + + /// + /// Initializes a new instance of + /// + /// The feature flag that should be enabled. + public RequireFeatureAttribute(string featureFlagKey) + { + ArgumentNullException.ThrowIfNull(featureFlagKey); + + _stringRepresentation = $"Flag = {featureFlagKey}"; + FeatureCheck = (featureService) => featureService.IsEnabled(featureFlagKey); + } + + internal RequireFeatureAttribute(Func featureCheck) + { + ArgumentNullException.ThrowIfNull(featureCheck); + + FeatureCheck = featureCheck; + _stringRepresentation = "Custom Feature Check"; + } + + /// + public Func FeatureCheck { get; set; } + + /// + public override string ToString() + { + return _stringRepresentation; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs b/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs index 0d44d21..cc5bf4b 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs @@ -1,4 +1,4 @@ -namespace Bitwarden.Extensions.Hosting; +namespace Bitwarden.Extensions.Hosting; /// /// Global settings. diff --git a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs index 73d0d44..7612f5b 100644 --- a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs +++ b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs @@ -1,15 +1,15 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Reflection; using Bitwarden.Extensions.Hosting; -using Microsoft.AspNetCore.Hosting; +using Bitwarden.Extensions.Hosting.Features; +using LaunchDarkly.Sdk.Server.Interfaces; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Serilog; -using Serilog.Events; using Serilog.Formatting.Compact; namespace Microsoft.Extensions.Hosting; @@ -21,34 +21,36 @@ public static class HostBuilderExtensions { const string SelfHostedConfigKey = "globalSettings:selfHosted"; - /// - /// Gets a logger that is suitable for use during the bootstrapping (startup) process. - /// - /// - public static ILogger GetBootstrapLogger() - { - return new LoggerConfiguration() - .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .Enrich.FromLogContext() - .WriteTo.Console() - .CreateBootstrapLogger(); - } - /// /// Configures the host to use Bitwarden defaults. /// + /// The host application builder. + /// The function to customize the Bitwarden defaults. + /// The original host application builder parameter. public static TBuilder UseBitwardenDefaults(this TBuilder builder, Action? configure = null) where TBuilder : IHostApplicationBuilder { + ArgumentNullException.ThrowIfNull(builder); + var bitwardenHostOptions = new BitwardenHostOptions(); configure?.Invoke(bitwardenHostOptions); builder.UseBitwardenDefaults(bitwardenHostOptions); return builder; } + /// + /// Configures the host to use Bitwarden defaults. + /// + /// + /// + /// + /// public static TBuilder UseBitwardenDefaults(this TBuilder builder, BitwardenHostOptions bitwardenHostOptions) where TBuilder : IHostApplicationBuilder { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(bitwardenHostOptions); + builder.Services.AddOptions() .Configure((options, config) => { @@ -71,6 +73,8 @@ public static TBuilder UseBitwardenDefaults(this TBuilder builder, Bit AddMetrics(builder.Services); } + AddFeatureFlagServices(builder.Services, builder.Configuration); + return builder; } @@ -126,6 +130,11 @@ public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, B }); } + hostBuilder.ConfigureServices((context, services) => + { + AddFeatureFlagServices(services, context.Configuration); + }); + return hostBuilder; } @@ -191,11 +200,19 @@ private static void AddLogging(IServiceCollection services, IConfiguration confi { services.AddSerilog((sp, serilog) => { - serilog.ReadFrom.Configuration(configuration) + var builder = serilog.ReadFrom.Configuration(configuration) .ReadFrom.Services(sp) .Enrich.WithProperty("Project", environment.ApplicationName) - .Enrich.FromLogContext() - .WriteTo.Console(new RenderedCompactJsonFormatter()); + .Enrich.FromLogContext(); + + if (environment.IsProduction()) + { + builder.WriteTo.Console(new RenderedCompactJsonFormatter()); + } + else + { + builder.WriteTo.Console(); + } }); } @@ -207,4 +224,21 @@ private static void AddMetrics(IServiceCollection services) .WithTracing(options => options.AddOtlpExporter()); } + + private static void AddFeatureFlagServices(IServiceCollection services, IConfiguration configuration) + { + services.AddProblemDetails(); + services.AddHttpContextAccessor(); + + services.Configure(configuration.GetSection("Features")); + // TODO: Register service to do legacy support from configuration. + + services.TryAddSingleton(); + + // This needs to be scoped so a "new" ILdClient can be given per request, this makes it possible to + // have the ILdClient be rebuilt if configuration changes but for the most part this will return a cached + // client from LaunchDarklyClientProvider, effectively being a singleton. + services.TryAddScoped(sp => sp.GetRequiredService().Get()); + services.TryAddScoped(); + } } diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs new file mode 100644 index 0000000..c2f9d2f --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using Microsoft.Extensions.Hosting; + +namespace Bitwarden.Extensions.Hosting.Utilities; + +internal static class HostEnvironmentExtensions +{ + public static VersionInfo? GetVersionInfo(this IHostEnvironment hostEnvironment) + { + try + { + var appAssembly = Assembly.Load(new AssemblyName(hostEnvironment.ApplicationName)); + var assemblyInformationalVersionAttribute = appAssembly + .GetCustomAttribute(); + + if (assemblyInformationalVersionAttribute == null) + { + return null; + } + + if (!VersionInfo.TryParse(assemblyInformationalVersionAttribute.InformationalVersion, null, out var versionInfo)) + { + return null; + } + + return versionInfo; + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs new file mode 100644 index 0000000..03c97bb --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Bitwarden.Extensions.Hosting; + +internal sealed partial class VersionInfo : ISpanParsable +{ + [GeneratedRegex("[0-9a-f]{5,40}")] + private static partial Regex GitHashRegex(); + + private VersionInfo(Version version, string? gitHash) + { + Version = version; + GitHash = gitHash; + } + + public Version Version { get; } + public string? GitHash { get; } + + /// + public static VersionInfo Parse(ReadOnlySpan s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + { + throw new FormatException(); + } + + return result; + } + + /// + public static VersionInfo Parse(string? s, IFormatProvider? provider) + => Parse(s.AsSpan(), provider); + + /// + public static bool TryParse( + ReadOnlySpan s, + IFormatProvider? provider, + [MaybeNullWhen(returnValue: false)] out VersionInfo result) + { + result = null; + var plusIndex = s.IndexOf('+'); + + if (plusIndex == -1) + { + // No split char, treat it as version only + if (!Version.TryParse(s, out var versionOnly)) + { + return false; + } + + result = new VersionInfo(versionOnly, null); + return true; + } + + if (!Version.TryParse(s[0..plusIndex], out var version)) + { + return false; + } + + var gitHash = s[++plusIndex..]; + + if (!GitHashRegex().IsMatch(gitHash)) + { + return false; + } + + result = new VersionInfo(version, gitHash.ToString()); + return true; + } + + /// + public static bool TryParse(string? s, IFormatProvider? provider, [MaybeNullWhen(returnValue: false)] out VersionInfo result) + => TryParse(s.AsSpan(), provider, out result); +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj index e203e24..5568240 100644 --- a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs new file mode 100644 index 0000000..5154ba2 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs @@ -0,0 +1,54 @@ +using Bitwarden.Extensions.Hosting.Features; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; + +namespace Bitwarden.Extensions.Hosting.Tests.Features; + +public class FeatureApplicationBuilderExtensionsTests +{ + [Fact] + public async Task UseFeatureFlagChecks_RegistersMiddleware() + { + // Arrange + var featureService = Substitute.For(); + var services = CreateServices(featureService); + + var app = new ApplicationBuilder(services); + + app.UseFeatureFlagChecks(); + + var appFunc = app.Build(); + + var endpoint = new Endpoint( + null, + new EndpointMetadataCollection(new RequireFeatureAttribute("test-attribute")), + "Test endpoint"); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = services; + httpContext.SetEndpoint(endpoint); + + // Act + await appFunc(httpContext); + + // Assert + featureService.Received(1).IsEnabled("test-attribute"); + } + + private IServiceProvider CreateServices(IFeatureService featureService) + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddProblemDetails(options => { }); + services.AddSingleton(Substitute.For()); + services.AddSingleton(featureService); + + var serviceProvder = services.BuildServiceProvider(); + + return serviceProvder; + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs new file mode 100644 index 0000000..c2b1bec --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Net.Http.Json; +using Bitwarden.Extensions.Hosting.Features; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Bitwarden.Extensions.Hosting.Tests.Features; + +public class FeatureCheckMiddlewareTests +{ + private readonly IProblemDetailsService _fakeProblemDetailsService; + private readonly IHostEnvironment _fakeHostEnvironment; + + public FeatureCheckMiddlewareTests() + { + _fakeProblemDetailsService = Substitute.For(); + _fakeHostEnvironment = Substitute.For(); + } + + [Fact] + public async Task NoEndpointInvokesPipeline() + { + bool pipelineInvoked = false; + var middleware = new FeatureCheckMiddleware(hc => + { + pipelineInvoked = true; + return Task.CompletedTask; + }, _fakeHostEnvironment, _fakeProblemDetailsService, NullLogger.Instance); + + var httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(null); + + var featureService = Substitute.For(); + + await middleware.Invoke(httpContext, featureService); + + Assert.True(pipelineInvoked); + } + + public static IEnumerable HasMetadataData() + { + yield return Row([new RequireFeatureAttribute("configured-true")], StatusCodes.Status200OK); + yield return Row([new RequireFeatureAttribute("configured-false")], StatusCodes.Status404NotFound); + yield return Row([new RequireFeatureAttribute("configured-true"), new RequireFeatureAttribute("configured-false")], StatusCodes.Status404NotFound); + yield return Row([new RequireFeatureAttribute("configured-true"), new RequireFeatureAttribute("configured-true")], StatusCodes.Status200OK); + yield return Row([new RequireFeatureAttribute("configured-false"), new RequireFeatureAttribute("configured-false")], StatusCodes.Status404NotFound); + yield return Row([], StatusCodes.Status200OK); + yield return Row([new RequireFeatureAttribute("not-configured")], StatusCodes.Status404NotFound); + + static object[] Row(IFeatureMetadata[] featureMetadata, int expectedStatusCode) + { + return [featureMetadata, expectedStatusCode]; + } + } + + [Theory] + [MemberData(nameof(HasMetadataData))] + public async Task HasMetadata_AllMustBeTrue(object[] metadata, int expectedStatusCode) + { + var middleware = new FeatureCheckMiddleware(hc => + { + hc.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, _fakeHostEnvironment, _fakeProblemDetailsService, NullLogger.Instance); + + var context = GetContext(metadata); + + var featureService = Substitute.For(); + + featureService.IsEnabled(Arg.Any()).Returns(false); + featureService.IsEnabled("configured-true").Returns(true); + featureService.IsEnabled("configured-false").Returns(false); + + await middleware.Invoke(context, featureService); + + Assert.Equal(expectedStatusCode, context.Response.StatusCode); + } + + [Fact] + public async Task FailedCheck_ReturnsProblemDetails() + { + using var host = CreateHost(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + var response = await client.GetAsync("/require-feature"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problemDetails = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problemDetails); + Assert.Equal("Resource not found.", problemDetails.Title); + } + + [Fact] + public async Task NoCheck_CallsEndpoint() + { + using var host = CreateHost(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + var response = await client.GetAsync("/no-feature-requirement"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var successResponse = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(successResponse); + Assert.True(successResponse.Success); + } + + [Fact] + public async Task SuccessfulCheck_CallsEndpoint() + { + using var host = CreateHost(services => + { + services.AddFeatureFlagValues( + [ + KeyValuePair.Create("my-feature", "true"), + ] + ); + }); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + var response = await client.GetAsync("/require-feature"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var successResponse = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(successResponse); + Assert.True(successResponse.Success); + } + + private static DefaultHttpContext GetContext(params object[] metadata) + { + var httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "TestEndpoint")); + return httpContext; + } + + record SuccessResponse(bool Success = true); + + private static IHost CreateHost(Action? configureServices = null) + { + return new HostBuilder() + .UseEnvironment("Development") // To get easier to read logs + .UseBitwardenDefaults() + .ConfigureWebHost((webHostBuilder) => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + + configureServices?.Invoke(services); + }) + .Configure(app => + { + app.UseRouting(); + + app.UseFeatureFlagChecks(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/require-feature", () => new SuccessResponse()) + .RequireFeature("my-feature"); + + endpoints.MapGet("/no-feature-requirement", () => new SuccessResponse()); + }); + }); + }) + .Build(); + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureEndpointConventionBuilderExtensionsTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureEndpointConventionBuilderExtensionsTests.cs new file mode 100644 index 0000000..94218ee --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureEndpointConventionBuilderExtensionsTests.cs @@ -0,0 +1,69 @@ +using Bitwarden.Extensions.Hosting.Features; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Bitwarden.Extensions.Hosting.Tests.Features; + +public class FeatureEndpointConventionBuilderExtensionsTests +{ + [Fact] + public void RequireFeature_ChainedCall() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + var chainedBuilder = builder.RequireFeature("feature-key"); + + // Assert + Assert.True(chainedBuilder.TestProperty); + } + + [Fact] + public void RequireFeature_WithFeatureKey() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + builder.RequireFeature("feature-key"); + + // Assert + var convention = Assert.Single(builder.Conventions); + + var endpointModel = new RouteEndpointBuilder((c) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0); + convention(endpointModel); + + Assert.IsAssignableFrom(Assert.Single(endpointModel.Metadata)); + } + + [Fact] + public void RequireFeature_WithCallback() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + builder.RequireFeature(featureService => featureService.IsEnabled("my-feature")); + + // Assert + var convention = Assert.Single(builder.Conventions); + + var endpointModel = new RouteEndpointBuilder((c) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0); + convention(endpointModel); + + Assert.IsAssignableFrom(Assert.Single(endpointModel.Metadata)); + } + + private class TestEndpointConventionBuilder : IEndpointConventionBuilder + { + public IList> Conventions { get; } = new List>(); + public bool TestProperty { get; } = true; + + public void Add(Action convention) + { + Conventions.Add(convention); + } + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureServiceCollectionExtensionsTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..91bd948 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureServiceCollectionExtensionsTests.cs @@ -0,0 +1,89 @@ +using Bitwarden.Extensions.Hosting.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bitwarden.Extensions.Hosting.Tests.Features; + +public class FeatureServiceCollectionExtensionsTests +{ + [Fact] + public void AddKnownFeatureFlags_Works() + { + var services = new ServiceCollection(); + services.AddKnownFeatureFlags(["feature-one", "feature-two"]); + + var sp = services.BuildServiceProvider(); + + var featureFlagOptions = sp.GetRequiredService>().Value; + + Assert.Equal(["feature-one", "feature-two"], featureFlagOptions.KnownFlags); + } + + [Fact] + public void AddKnownFeatureFlags_MultipleTimes_AddsAll() + { + var services = new ServiceCollection(); + services.AddKnownFeatureFlags(["feature-one", "feature-two"]); + services.AddKnownFeatureFlags(["feature-three"]); + + var sp = services.BuildServiceProvider(); + + var featureFlagOptions = sp.GetRequiredService>().Value; + + Assert.Equal(["feature-one", "feature-two", "feature-three"], featureFlagOptions.KnownFlags); + } + + [Fact] + public void AddFeatureFlagValues_Works() + { + var services = new ServiceCollection(); + services.AddFeatureFlagValues( + [ + KeyValuePair.Create("feature-one", "true"), + KeyValuePair.Create("feature-two", "false"), + ] + ); + + var sp = services.BuildServiceProvider(); + + var featureFlagOptions = sp.GetRequiredService>().Value; + + var featureOneValue = Assert.Contains("feature-one", featureFlagOptions.FlagValues); + Assert.Equal("true", featureOneValue); + + var featureTwoValue = Assert.Contains("feature-two", featureFlagOptions.FlagValues); + Assert.Equal("false", featureTwoValue); + } + + [Fact] + public void AddFeatureFlagValues_MultipleTimes_AddMoreAndOverwritesExisting() + { + var services = new ServiceCollection(); + services.AddFeatureFlagValues( + [ + KeyValuePair.Create("feature-one", "true"), + KeyValuePair.Create("feature-two", "false"), + ] + ); + + services.AddFeatureFlagValues( + [ + KeyValuePair.Create("feature-one", "value"), // Override existing value + KeyValuePair.Create("feature-three", "1"), + ] + ); + + var sp = services.BuildServiceProvider(); + + var featureFlagOptions = sp.GetRequiredService>().Value; + + var featureOneValue = Assert.Contains("feature-one", featureFlagOptions.FlagValues); + Assert.Equal("value", featureOneValue); + + var featureTwoValue = Assert.Contains("feature-two", featureFlagOptions.FlagValues); + Assert.Equal("false", featureTwoValue); + + var featureThreeValue = Assert.Contains("feature-three", featureFlagOptions.FlagValues); + Assert.Equal("1", featureThreeValue); + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/LaunchDarklyFeatureServiceTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/LaunchDarklyFeatureServiceTests.cs new file mode 100644 index 0000000..fc2e150 --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/LaunchDarklyFeatureServiceTests.cs @@ -0,0 +1,137 @@ +using Bitwarden.Extensions.Hosting.Features; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +using LaunchDarkly.Sdk.Server.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Bitwarden.Extensions.Hosting.Tests.Features; + +public class LaunchDarklyFeatureServiceTests +{ + private readonly ILdClient _ldClient; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptionsMonitor _featureFlagOptions; + + private readonly LaunchDarklyFeatureService _sut; + + public LaunchDarklyFeatureServiceTests() + { + _ldClient = Substitute.For(); + _httpContextAccessor = Substitute.For(); + _featureFlagOptions = Substitute.For>(); + + _sut = new LaunchDarklyFeatureService( + _ldClient, + _httpContextAccessor, + _featureFlagOptions, + NullLogger.Instance + ); + } + + [Fact] + public void GetAll() + { + var flagsState = FeatureFlagsState.Builder() + .AddFlag("feature-one", new EvaluationDetail(LdValue.Of(true), 0, default)) + .AddFlag("feature-two", new EvaluationDetail(LdValue.Of(1), 1, default)) + .AddFlag("feature-three", new EvaluationDetail(LdValue.Of("test-value"), 2, default)) + .Build(); + + _ldClient.AllFlagsState(Arg.Any()) + .Returns(flagsState); + + _featureFlagOptions.CurrentValue.Returns(new FeatureFlagOptions + { + KnownFlags = ["feature-one", "feature-two", "feature-three"], + }); + + var allFlags = _sut.GetAll(); + + Assert.Equal(3, allFlags.Count); + + var featureOneValue = Assert.Contains("feature-one", allFlags); + Assert.True(featureOneValue.GetValue()); + + var featureTwoValue = Assert.Contains("feature-two", allFlags); + Assert.Equal(1, featureTwoValue.GetValue()); + + var featureThreeValue = Assert.Contains("feature-three", allFlags); + Assert.Equal("test-value", featureThreeValue.GetValue()); + } + + [Fact] + public void GetAll_OnlyReturnsKnownFlags() + { + var flagsState = FeatureFlagsState.Builder() + .AddFlag("feature-one", new EvaluationDetail(LdValue.Of(true), 0, default)) + .AddFlag("feature-two", new EvaluationDetail(LdValue.Of(true), 1, default)) + .Build(); + + _ldClient.AllFlagsState(Arg.Any()) + .Returns(flagsState); + + _featureFlagOptions.CurrentValue.Returns(new FeatureFlagOptions + { + KnownFlags = ["feature-one"], + }); + + var allFlags = _sut.GetAll(); + + Assert.Single(allFlags); + var flagValue = Assert.Contains("feature-one", allFlags); + Assert.True(flagValue.GetValue()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEnabled_PassesAlongDetails(bool defaultValue) + { + _ldClient + .BoolVariation("feature-one", Arg.Any(), defaultValue) + .Returns(true); + + Assert.True(_sut.IsEnabled("feature-one", defaultValue)); + } + + [Fact] + public void IsEnabled_MultipleCalls_BuildsContextOnce() + { + _ = _sut.IsEnabled("feature-one"); + _ = _sut.IsEnabled("feature-one"); + + // Use the access of the HttpContext as the indicator that it was only built from once + _httpContextAccessor + .HttpContext + .Received(1); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(int.MaxValue)] + public void GetIntVariation_PassesAlongDetails(int defaultValue) + { + _ldClient + .IntVariation("feature-one", Arg.Any(), defaultValue) + .Returns(1); + + Assert.Equal(1, _sut.GetIntVariation("feature-one", defaultValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("test")] + public void GetStringVariation_PassesAlongDetails(string? defaultValue) + { + _ldClient + .StringVariation("feature-one", Arg.Any(), defaultValue) + .Returns("my-value"); + + Assert.Equal("my-value", _sut.GetStringVariation("feature-one", defaultValue)); + } +} diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Utilities/VersionInfoTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Utilities/VersionInfoTests.cs new file mode 100644 index 0000000..00d618f --- /dev/null +++ b/extensions/Bitwarden.Extensions.Hosting/tests/Utilities/VersionInfoTests.cs @@ -0,0 +1,29 @@ +namespace Bitwarden.Extensions.Hosting.Tests.Utilities; + +public class VersionInfoTests +{ + [Theory] + [InlineData("1.0.0", "1.0.0", null)] + [InlineData("1.0.0+af18b2952b5ddf910bd2f729a7c89a04b8d67084", "1.0.0", "af18b2952b5ddf910bd2f729a7c89a04b8d67084")] + [InlineData("1.0.0+af18b", "1.0.0", "af18b")] + public void TryParse_Works(string input, string version, string? gitHash) + { + var success = VersionInfo.TryParse(input, null, out var versionInfo); + + Assert.True(success); + Assert.NotNull(versionInfo); + Assert.Equal(version, versionInfo.Version.ToString()); + Assert.Equal(gitHash, versionInfo.GitHash); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("1.0.0+")] + [InlineData("1.0.0+af18")] + [InlineData("1.0.0+XXXXXXX")] + public void TryParse_Fails(string? input) + { + Assert.False(VersionInfo.TryParse(input, null, out _)); + } +}