From bd666841a546e19935167166ba84553da7bc1b72 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Fri, 10 Mar 2023 08:11:11 -0500 Subject: [PATCH] All feature state access through config API (#2785) --- src/Api/Controllers/ConfigController.cs | 13 ++++- .../Models/Response/ConfigResponseModel.cs | 9 +++- src/Core/Constants.cs | 12 ++++- src/Core/Services/IFeatureService.cs | 7 +++ .../LaunchDarklyFeatureService.cs | 32 +++++++++++++ .../Controllers/ConfigControllerTests.cs | 30 ++++++++++++ .../Controllers/ConfigControllerTests.cs | 47 +++++++++++++++++++ .../LaunchDarklyFeatureServiceTests.cs | 14 ++++++ 8 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 test/Api.IntegrationTest/Controllers/ConfigControllerTests.cs create mode 100644 test/Api.Test/Controllers/ConfigControllerTests.cs diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs index 51d224bc6e..167de7c905 100644 --- a/src/Api/Controllers/ConfigController.cs +++ b/src/Api/Controllers/ConfigController.cs @@ -1,4 +1,6 @@ using Bit.Api.Models.Response; +using Bit.Core.Context; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Mvc; @@ -9,15 +11,22 @@ namespace Bit.Api.Controllers; public class ConfigController : Controller { private readonly IGlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; - public ConfigController(IGlobalSettings globalSettings) + public ConfigController( + IGlobalSettings globalSettings, + ICurrentContext currentContext, + IFeatureService featureService) { _globalSettings = globalSettings; + _currentContext = currentContext; + _featureService = featureService; } [HttpGet("")] public ConfigResponseModel GetConfigs() { - return new ConfigResponseModel(_globalSettings); + return new ConfigResponseModel(_globalSettings, _featureService.GetAll(_currentContext)); } } diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index 019ee161c4..2e85a3a30d 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -10,15 +10,19 @@ public class ConfigResponseModel : ResponseModel public string GitHash { get; set; } public ServerConfigResponseModel Server { get; set; } public EnvironmentConfigResponseModel Environment { get; set; } + public IDictionary FeatureStates { get; set; } - public ConfigResponseModel(string obj = "config") : base(obj) + public ConfigResponseModel() : base("config") { Version = AssemblyHelpers.GetVersion(); GitHash = AssemblyHelpers.GetGitHash(); Environment = new EnvironmentConfigResponseModel(); + FeatureStates = new Dictionary(); } - public ConfigResponseModel(IGlobalSettings globalSettings, string obj = "config") : base(obj) + public ConfigResponseModel( + IGlobalSettings globalSettings, + IDictionary featureStates) : base("config") { Version = AssemblyHelpers.GetVersion(); GitHash = AssemblyHelpers.GetGitHash(); @@ -30,6 +34,7 @@ public class ConfigResponseModel : ResponseModel Notifications = globalSettings.BaseServiceUri.Notifications, Sso = globalSettings.BaseServiceUri.Sso }; + FeatureStates = featureStates; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 19117f1b79..7037a37be9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -1,4 +1,6 @@ -namespace Bit.Core; +using System.Reflection; + +namespace Bit.Core; public static class Constants { @@ -26,4 +28,12 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { public const string SecretsManager = "secrets-manager"; + + public static List GetAllKeys() + { + return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string)) + .Select(x => (string)x.GetRawConstantValue()) + .ToList(); + } } diff --git a/src/Core/Services/IFeatureService.cs b/src/Core/Services/IFeatureService.cs index 0d8e7a4222..a85b16ec8e 100644 --- a/src/Core/Services/IFeatureService.cs +++ b/src/Core/Services/IFeatureService.cs @@ -36,4 +36,11 @@ public interface IFeatureService /// The default value for the feature. /// The feature variation value. string GetStringVariation(string key, ICurrentContext currentContext, string defaultValue = null); + + /// + /// Gets all feature values. + /// + /// A context providing information that can be used to evaluate the feature values. + /// A dictionary of feature keys and their values. + Dictionary GetAll(ICurrentContext currentContext); } diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index eeb2e57238..dac309a1f3 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -63,6 +63,38 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable return _client.StringVariation(key, BuildContext(currentContext), defaultValue); } + public Dictionary GetAll(ICurrentContext currentContext) + { + var results = new Dictionary(); + + var keys = FeatureFlagKeys.GetAllKeys(); + + var values = _client.AllFlagsState(BuildContext(currentContext)); + if (values.Valid) + { + foreach (var key in keys) + { + var value = values.GetFlagValueJson(key); + switch (value.Type) + { + case LaunchDarkly.Sdk.LdValueType.Bool: + results.Add(key, value.AsBool); + break; + + case LaunchDarkly.Sdk.LdValueType.Number: + results.Add(key, value.AsInt); + break; + + case LaunchDarkly.Sdk.LdValueType.String: + results.Add(key, value.AsString); + break; + } + } + } + + return results; + } + public void Dispose() { _client?.Dispose(); diff --git a/test/Api.IntegrationTest/Controllers/ConfigControllerTests.cs b/test/Api.IntegrationTest/Controllers/ConfigControllerTests.cs new file mode 100644 index 0000000000..bafd7dcb52 --- /dev/null +++ b/test/Api.IntegrationTest/Controllers/ConfigControllerTests.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.Models.Response; +using Xunit; + +namespace Bit.Api.IntegrationTest.Controllers; + +public class ConfigControllerTests : IClassFixture +{ + private readonly ApiApplicationFactory _factory; + + public ConfigControllerTests(ApiApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task GetConfigs() + { + var tokens = await _factory.LoginWithNewAccount(); + var client = _factory.CreateClient(); + + using var message = new HttpRequestMessage(HttpMethod.Get, "/config"); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var response = await client.SendAsync(message); + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadFromJsonAsync(); + + Assert.NotEmpty(content!.Version); + } +} diff --git a/test/Api.Test/Controllers/ConfigControllerTests.cs b/test/Api.Test/Controllers/ConfigControllerTests.cs new file mode 100644 index 0000000000..d9b857194c --- /dev/null +++ b/test/Api.Test/Controllers/ConfigControllerTests.cs @@ -0,0 +1,47 @@ +using AutoFixture.Xunit2; +using Bit.Api.Controllers; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Core.Settings; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Controllers; + +public class ConfigControllerTests : IDisposable +{ + private readonly ConfigController _sut; + private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly ICurrentContext _currentContext; + + public ConfigControllerTests() + { + _globalSettings = new GlobalSettings(); + _currentContext = Substitute.For(); + _featureService = Substitute.For(); + + _sut = new ConfigController( + _globalSettings, + _currentContext, + _featureService + ); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory, AutoData] + public void GetConfigs_WithFeatureStates(Dictionary featureStates) + { + _featureService.GetAll(_currentContext).Returns(featureStates); + + var response = _sut.GetConfigs(); + + Assert.NotNull(response); + Assert.NotNull(response.FeatureStates); + Assert.Equal(featureStates, response.FeatureStates); + } +} diff --git a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs index efc84bd784..84055cd929 100644 --- a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs +++ b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs @@ -91,4 +91,18 @@ public class LaunchDarklyFeatureServiceTests Assert.Null(sutProvider.Sut.GetStringVariation(FeatureFlagKeys.SecretsManager, currentContext)); } + + [Fact(Skip = "For local development")] + public void GetAll() + { + var sutProvider = GetSutProvider(new Core.Settings.GlobalSettings()); + + var currentContext = Substitute.For(); + currentContext.UserId.Returns(Guid.NewGuid()); + + var results = sutProvider.Sut.GetAll(currentContext); + + Assert.NotNull(results); + Assert.NotEmpty(results); + } }