From 854abb0993ccc6be05b400f269674920adf4586d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 12 Sep 2025 13:44:19 -0400 Subject: [PATCH] [PM-23845] Update cache service to handle concurrency (#6170) --- .../IApplicationCacheServiceBusMessaging.cs | 10 + ...VCurrentInMemoryApplicationCacheService.cs | 19 + .../IVNextInMemoryApplicationCacheService.cs | 19 + .../NoOpApplicationCacheMessaging.cs | 21 + .../ServiceBusApplicationCacheMessaging.cs | 63 ++ .../VNextInMemoryApplicationCacheService.cs | 137 +++++ src/Core/Constants.cs | 2 + .../ApplicationCacheHostedService.cs | 5 +- .../FeatureRoutedCacheService.cs | 152 +++++ .../InMemoryApplicationCacheService.cs | 3 +- ...MemoryServiceBusApplicationCacheService.cs | 5 +- src/Events/Startup.cs | 11 +- .../Utilities/ServiceCollectionExtensions.cs | 11 +- ...extInMemoryApplicationCacheServiceTests.cs | 403 +++++++++++++ .../FeatureRoutedCacheServiceTests.cs | 541 ++++++++++++++++++ 15 files changed, 1392 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/Services/Implementations/FeatureRoutedCacheService.cs create mode 100644 test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs create mode 100644 test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs diff --git a/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs new file mode 100644 index 0000000000..d0cecfb10d --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IApplicationCacheServiceBusMessaging +{ + Task NotifyOrganizationAbilityUpsertedAsync(Organization organization); + Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId); + Task NotifyProviderAbilityDeletedAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..e8152b1e98 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVCurrentInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..57109ba6a7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVNextInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs new file mode 100644 index 0000000000..36a380a850 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class NoOpApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + public Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + return Task.CompletedTask; + } + + public Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + return Task.CompletedTask; + } + + public Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs new file mode 100644 index 0000000000..f267871da7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs @@ -0,0 +1,63 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class ServiceBusApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + private readonly ServiceBusSender _topicMessageSender; + private readonly string _subName; + + public ServiceBusApplicationCacheMessaging( + GlobalSettings globalSettings) + { + _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); + var serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = serviceBusClient.CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); + } + + public async Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.UpsertOrganizationAbility }, + { "id", organization.Id }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteOrganizationAbility }, + { "id", organizationId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility }, + { "id", providerId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..409074e3b2 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class VNextInMemoryApplicationCacheService( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + TimeProvider timeProvider) : IVNextInMemoryApplicationCacheService +{ + private ConcurrentDictionary _orgAbilities = new(); + private readonly SemaphoreSlim _orgInitLock = new(1, 1); + private DateTimeOffset _lastOrgAbilityRefresh = DateTimeOffset.MinValue; + + private ConcurrentDictionary _providerAbilities = new(); + private readonly SemaphoreSlim _providerInitLock = new(1, 1); + private DateTimeOffset _lastProviderAbilityRefresh = DateTimeOffset.MinValue; + + private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10); + + public virtual async Task> GetOrganizationAbilitiesAsync() + { + await InitOrganizationAbilitiesAsync(); + return _orgAbilities; + } + + public async Task GetOrganizationAbilityAsync(Guid organizationId) + { + (await GetOrganizationAbilitiesAsync()) + .TryGetValue(organizationId, out var organizationAbility); + return organizationAbility; + } + + public virtual async Task> GetProviderAbilitiesAsync() + { + await InitProviderAbilitiesAsync(); + return _providerAbilities; + } + + public virtual async Task UpsertProviderAbilityAsync(Provider provider) + { + await InitProviderAbilitiesAsync(); + _providerAbilities.AddOrUpdate( + provider.Id, + static (_, provider) => new ProviderAbility(provider), + static (_, _, provider) => new ProviderAbility(provider), + provider); + } + + public virtual async Task UpsertOrganizationAbilityAsync(Organization organization) + { + await InitOrganizationAbilitiesAsync(); + + _orgAbilities.AddOrUpdate( + organization.Id, + static (_, organization) => new OrganizationAbility(organization), + static (_, _, organization) => new OrganizationAbility(organization), + organization); + } + + public virtual Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + _orgAbilities.TryRemove(organizationId, out _); + return Task.CompletedTask; + } + + public virtual Task DeleteProviderAbilityAsync(Guid providerId) + { + _providerAbilities.TryRemove(providerId, out _); + return Task.CompletedTask; + } + + private async Task InitOrganizationAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _orgAbilities = dict, + () => _lastOrgAbilityRefresh, + dt => _lastOrgAbilityRefresh = dt, + _orgInitLock, + async () => await organizationRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + private async Task InitProviderAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _providerAbilities = dict, + () => _lastProviderAbilityRefresh, + dateTime => _lastProviderAbilityRefresh = dateTime, + _providerInitLock, + async () => await providerRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + + private async Task InitAbilitiesAsync( + Action> setCache, + Func getLastRefresh, + Action setLastRefresh, + SemaphoreSlim @lock, + Func>> fetchFunc, + TimeSpan refreshInterval, + Func getId) + { + if (SkipRefresh()) + { + return; + } + + await @lock.WaitAsync(); + try + { + if (SkipRefresh()) + { + return; + } + + var sources = await fetchFunc(); + var abilities = new ConcurrentDictionary( + sources.ToDictionary(getId)); + setCache(abilities); + setLastRefresh(timeProvider.GetUtcNow()); + } + finally + { + @lock.Release(); + } + + bool SkipRefresh() + { + return timeProvider.GetUtcNow() - getLastRefresh() <= refreshInterval; + } + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ed9ee02dad..17dae1255c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,6 +131,8 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; + public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index ca2744bd10..655a713764 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -3,6 +3,7 @@ using Azure.Messaging.ServiceBus.Administration; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Hosting; @@ -14,7 +15,7 @@ namespace Bit.Core.HostedServices; public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; + private readonly FeatureRoutedCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -34,7 +35,7 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable { _topicName = globalSettings.ServiceBus.ApplicationCacheTopicName; _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _applicationCacheService = applicationCacheService as InMemoryServiceBusApplicationCacheService; + _applicationCacheService = applicationCacheService as FeatureRoutedCacheService; _organizationRepository = organizationRepository; _logger = logger; _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); diff --git a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs new file mode 100644 index 0000000000..b6294a28f8 --- /dev/null +++ b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs @@ -0,0 +1,152 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services.Implementations; + +/// +/// A feature-flagged routing service for application caching that bridges the gap between +/// scoped dependency injection (IFeatureService) and singleton services (cache implementations). +/// This service allows dynamic routing between IVCurrentInMemoryApplicationCacheService and +/// IVNextInMemoryApplicationCacheService based on the PM23845_VNextApplicationCache feature flag. +/// +/// +/// This service is necessary because: +/// - IFeatureService is registered as Scoped in the DI container +/// - IVNextInMemoryApplicationCacheService and IVCurrentInMemoryApplicationCacheService are registered as Singleton +/// - We need to evaluate feature flags at request time while maintaining singleton cache behavior +/// +/// The service acts as a scoped proxy that can access the scoped IFeatureService while +/// delegating actual cache operations to the appropriate singleton implementation. +/// +public class FeatureRoutedCacheService( + IFeatureService featureService, + IVNextInMemoryApplicationCacheService vNextInMemoryApplicationCacheService, + IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService, + IApplicationCacheServiceBusMessaging serviceBusMessaging) + : IApplicationCacheService +{ + public async Task> GetOrganizationAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + return await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + public async Task GetOrganizationAbilityAsync(Guid orgId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + return await inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + + public async Task> GetProviderAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + return await inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + + public async Task UpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + await serviceBusMessaging.NotifyOrganizationAbilityUpsertedAsync(organization); + } + else + { + await inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + public async Task UpsertProviderAbilityAsync(Provider provider) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + else + { + await inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + } + + public async Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + await serviceBusMessaging.NotifyOrganizationAbilityDeletedAsync(organizationId); + } + else + { + await inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + } + + public async Task DeleteProviderAbilityAsync(Guid providerId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + await serviceBusMessaging.NotifyProviderAbilityDeletedAsync(providerId); + } + else + { + await inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + } + + } + + public async Task BaseUpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } + + public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } +} diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index d1bece56c1..4062162701 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -10,7 +11,7 @@ using Bit.Core.Repositories; namespace Bit.Core.Services; -public class InMemoryApplicationCacheService : IApplicationCacheService +public class InMemoryApplicationCacheService : IVCurrentInMemoryApplicationCacheService { private readonly IOrganizationRepository _organizationRepository; private readonly IProviderRepository _providerRepository; diff --git a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs index da70ccd2fd..b856bfa749 100644 --- a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs @@ -8,9 +8,8 @@ using Bit.Core.Utilities; namespace Bit.Core.Services; -public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService, IApplicationCacheService +public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService { - private readonly ServiceBusClient _serviceBusClient; private readonly ServiceBusSender _topicMessageSender; private readonly string _subName; @@ -21,7 +20,7 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach : base(organizationRepository, providerRepository) { _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString).CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index fdeaad04b2..cfe177aa2c 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,7 +1,9 @@ using System.Globalization; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; @@ -52,13 +54,18 @@ public class Startup // Services var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName); + services.AddScoped(); + services.AddSingleton(); + if (usingServiceBusAppCache) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } services.AddEventWriteServices(globalSettings); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 592f7c84c3..d87f9ab97f 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -41,6 +42,7 @@ using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; @@ -247,14 +249,19 @@ public static class ServiceCollectionExtensions services.AddOptionality(); services.AddTokenizers(); + services.AddSingleton(); + services.AddScoped(); + if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName)) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } var awsConfigured = CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret); diff --git a/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs new file mode 100644 index 0000000000..afd3dccda3 --- /dev/null +++ b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs @@ -0,0 +1,403 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.AbilitiesCache; + +[SutProviderCustomize] +public class VNextInMemoryApplicationCacheServiceTests + +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_FirstCall_LoadsFromRepository( + ICollection organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(organizationAbilities.Count, result.Count); + foreach (var ability in organizationAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_SecondCall_UsesCachedValue( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_ExistingId_ReturnsAbility( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(targetAbility.Id); + + // Assert + Assert.Equal(targetAbility, result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_NonExistingId_ReturnsNull( + List organizationAbilities, + Guid nonExistingId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(nonExistingId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_FirstCall_LoadsFromRepository( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(providerAbilities.Count, result.Count); + foreach (var ability in providerAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_SecondCall_UsesCachedValue( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_NewOrganization_AddsToCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_ExistingOrganization_UpdatesCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + existingAbilities.Add(new OrganizationAbility { Id = organization.Id }); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_NewProvider_AddsToCache( + Provider provider, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.True(result.ContainsKey(provider.Id)); + Assert.Equal(provider.Id, result[provider.Id].Id); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_ExistingId_RemovesFromCache( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_ExistingId_RemovesFromCache( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = providerAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + [Theory, BitAutoData] + public async Task ConcurrentAccess_GetOrganizationAbilities_ThreadSafe( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var results = new ConcurrentBag>(); + + const int iterationCount = 100; + + + // Act + await Parallel.ForEachAsync( + Enumerable.Range(0, iterationCount), + async (_, _) => + { + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + results.Add(result); + }); + + // Assert + var firstCall = results.First(); + Assert.Equal(iterationCount, results.Count); + Assert.All(results, result => Assert.Same(firstCall, result)); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List organizationAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + const int pastIntervalInMinutes = 11; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List providerAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + const int pastIntervalMinutes = 15; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + public static IEnumerable WhenCacheIsWithinIntervalTestCases => + [ + [5, 1], + [10, 1], + ]; + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetOrganizationAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List organizationAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(organizationAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetProviderAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List providerAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(providerAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + private static void SimulateTimeLapseAfterFirstCall(SutProvider sutProvider, int pastIntervalInMinutes) => + sutProvider + .GetDependency() + .Advance(TimeSpan.FromMinutes(pastIntervalInMinutes)); + +} diff --git a/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs new file mode 100644 index 0000000000..3309f2bf23 --- /dev/null +++ b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs @@ -0,0 +1,541 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services.Implementations; + +[SutProviderCustomize] +public class FeatureRoutedCacheServiceTests +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertOrganizationAbilityAsync(organization); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Organization organization) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await currentCacheService + .Received(1) + .BaseUpsertOrganizationAbilityAsync(organization); + } + + /// + /// Our SUT is using a method that is not part of the IVCurrentInMemoryApplicationCacheService, + /// so AutoFixture’s auto-created mock won’t work. + /// + /// + private static InMemoryServiceBusApplicationCacheService CreateCurrentCacheMockService() + { + var currentCacheService = Substitute.For( + Substitute.For(), + Substitute.For(), + new GlobalSettings + { + ProjectName = "BitwardenTest", + ServiceBus = new GlobalSettings.ServiceBusSettings + { + ConnectionString = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ApplicationCacheTopicName = "test-topic", + ApplicationCacheSubscriptionName = "test-subscription" + } + }); + return currentCacheService; + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } + + private static string ExpectedErrorMessage + { + get => "Expected inMemoryApplicationCacheService to be of type InMemoryServiceBusApplicationCacheService"; + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Guid organizationId) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await currentCacheService + .Received(1) + .BaseDeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task + BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync(() => + sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } +}