diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs index 86de25ce9a..f1c96c8b98 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.AdminConsole.Entities; public class OrganizationIntegration : ITableObject diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs index 52934cf7f3..a9ce676062 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.AdminConsole.Entities; public class OrganizationIntegrationConfiguration : ITableObject diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 0a774cf395..fb42ffa000 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -6,10 +6,23 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationConfigurationRepository : IRepository { - Task> GetConfigurationDetailsAsync( + /// + /// Retrieve the list of available configuration details for a specific event for the organization and + /// integration type.
+ ///
+ /// Note: This returns all configurations that match the event type explicitly and + /// all the configurations that have a null event type - null event type is considered a + /// wildcard that matches all events. + /// + ///
+ /// The specific event type + /// The id of the organization + /// The integration type + /// A List of that match + Task> GetManyByEventTypeOrganizationIdIntegrationType( + EventType eventType, Guid organizationId, - IntegrationType integrationType, - EventType eventType); + IntegrationType integrationType); Task> GetAllConfigurationDetailsAsync(); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 4202ba770e..b4246884f7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Utilities; @@ -17,8 +18,8 @@ public class EventIntegrationHandler( IntegrationType integrationType, IEventIntegrationPublisher eventIntegrationPublisher, IIntegrationFilterService integrationFilterService, - IIntegrationConfigurationDetailsCache configurationCache, IFusionCache cache, + IOrganizationIntegrationConfigurationRepository configurationRepository, IGroupRepository groupRepository, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -27,17 +28,7 @@ public class EventIntegrationHandler( { public async Task HandleEventAsync(EventMessage eventMessage) { - if (eventMessage.OrganizationId is not Guid organizationId) - { - return; - } - - var configurations = configurationCache.GetConfigurationDetails( - organizationId, - integrationType, - eventMessage.Type); - - foreach (var configuration in configurations) + foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage)) { try { @@ -64,7 +55,7 @@ public class EventIntegrationHandler( { IntegrationType = integrationType, MessageId = messageId.ToString(), - OrganizationId = organizationId.ToString(), + OrganizationId = eventMessage.OrganizationId?.ToString(), Configuration = config, RenderedTemplate = renderedTemplate, RetryCount = 0, @@ -132,6 +123,37 @@ public class EventIntegrationHandler( return context; } + private async Task> GetConfigurationDetailsListAsync(EventMessage eventMessage) + { + if (eventMessage.OrganizationId is not Guid organizationId) + { + return []; + } + + List configurations = []; + + var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integrationType + ); + + configurations.AddRange(await cache.GetOrSetAsync>( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integrationType, + eventType: eventMessage.Type), + factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType( + eventType: eventMessage.Type, + organizationId: organizationId, + integrationType: integrationType), + options: new FusionCacheEntryOptions( + duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails), + tags: [integrationTag] + )); + + return configurations; + } + private async Task GetUserFromCacheAsync(Guid organizationId, Guid userId) => await cache.GetOrSetAsync( key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId), diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs deleted file mode 100644 index a63efac62f..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache -{ - private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType); - private readonly IOrganizationIntegrationConfigurationRepository _repository; - private readonly ILogger _logger; - private readonly TimeSpan _refreshInterval; - private Dictionary> _cache = new(); - - public IntegrationConfigurationDetailsCacheService( - IOrganizationIntegrationConfigurationRepository repository, - GlobalSettings globalSettings, - ILogger logger) - { - _repository = repository; - _logger = logger; - _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); - } - - public List GetConfigurationDetails( - Guid organizationId, - IntegrationType integrationType, - EventType eventType) - { - var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType); - var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null); - - var results = new List(); - - if (_cache.TryGetValue(specificKey, out var specificConfigs)) - { - results.AddRange(specificConfigs); - } - if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs)) - { - results.AddRange(fallbackConfigs); - } - - return results; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await RefreshAsync(); - - var timer = new PeriodicTimer(_refreshInterval); - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - await RefreshAsync(); - } - } - - internal async Task RefreshAsync() - { - var stopwatch = Stopwatch.StartNew(); - try - { - var newCache = (await _repository.GetAllConfigurationDetailsAsync()) - .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) - .ToDictionary(g => g.Key, g => g.ToList()); - _cache = newCache; - - stopwatch.Stop(); - _logger.LogInformation( - "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", - newCache.Count, - stopwatch.Elapsed.TotalMilliseconds); - } - catch (Exception ex) - { - _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); - } - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 7570d47211..a1d7793d37 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -295,33 +295,59 @@ graph TD ``` ## Caching -To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary -with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +To reduce database load and improve performance, event integrations uses its own named extended cache (see +the [README in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/README.md#extended-cache) +for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. -By loading all configurations into memory on a fixed interval, we ensure: +### `EventIntegrationsCacheConstants` -- Consistent performance for reads. -- Reduced database pressure. -- Predictable refresh timing, independent of event activity. +`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related +details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed +from `EventIntegrationsCacheConstants` rather than simple strings. For instance, +`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc., +rather than using a string literal (i.e. "EventIntegrations") in code. -### Architecture / Design +### `OrganizationIntegrationConfigurationDetails` -- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. -- The cache is fully replaced on each refresh to avoid locking or partial state. +- This is one of the most actively used portions of the architecture because any event that has an associated + organization requires a check of the configurations to determine if we need to fire off an integration. +- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database. - Reads return a `List` for a given key or an empty list if no match exists. -- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving - the last known good state until the update replaces the whole cache. +- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it + tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane, + which means that the cache is then expired and the next read will fetch the new values. This allows us to have + a high TTL and avoid needing to refresh values except when necessary. -### Background Refresh +#### Tagging per integration -A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: +- Each entry in the cache (which again, returns `List`) is tagged with + the organization id and the integration type. +- This allows us to remove all of a given organization's configuration details for an integration when the admin + makes changes at the integration level. + - For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL + at the integration level, the updates would need to be propagated or else the cache will continue returning the + stale URL. + - By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given + organization integration in one call. The cache will handle dropping / refreshing these entries in a + performant way. +- There are two places in the code that are both aware of the tagging functionality + - The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache + to store the entry with the tag when it successfully loads from the repository. + - The `OrganizationIntegrationController` needs to use the tag to remove all the tagged entries when and admin + creates, updates, or deletes an integration. + - To ensure both places are synchronized on how to tag entries, they both use + `EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag. -- Loads all configuration records at application startup. -- Refreshes the cache on a configurable interval. -- Logs timing and entry count on success. -- Logs exceptions on failure without disrupting application flow. +### Template Properties + +- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance, + the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user + id to the actual name. +- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the + extended cache with a default TTL of 30 minutes. +- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed. # Building a new integration diff --git a/src/Core/Utilities/EventIntegrationsCacheConstants.cs b/src/Core/Utilities/EventIntegrationsCacheConstants.cs index f3ba99fd12..6bd90c797d 100644 --- a/src/Core/Utilities/EventIntegrationsCacheConstants.cs +++ b/src/Core/Utilities/EventIntegrationsCacheConstants.cs @@ -1,4 +1,6 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Utilities; @@ -11,7 +13,12 @@ public static class EventIntegrationsCacheConstants /// /// The base cache name used for storing event integration data. /// - public static readonly string CacheName = "EventIntegrations"; + public const string CacheName = "EventIntegrations"; + + /// + /// Duration TimeSpan for adding OrganizationIntegrationConfigurationDetails to the cache. + /// + public static readonly TimeSpan DurationForOrganizationIntegrationConfigurationDetails = TimeSpan.FromDays(1); /// /// Builds a deterministic cache key for a . @@ -20,10 +27,8 @@ public static class EventIntegrationsCacheConstants /// /// A cache key for this Group. /// - public static string BuildCacheKeyForGroup(Guid groupId) - { - return $"Group:{groupId:N}"; - } + public static string BuildCacheKeyForGroup(Guid groupId) => + $"Group:{groupId:N}"; /// /// Builds a deterministic cache key for an . @@ -32,10 +37,8 @@ public static class EventIntegrationsCacheConstants /// /// A cache key for the Organization. /// - public static string BuildCacheKeyForOrganization(Guid organizationId) - { - return $"Organization:{organizationId:N}"; - } + public static string BuildCacheKeyForOrganization(Guid organizationId) => + $"Organization:{organizationId:N}"; /// /// Builds a deterministic cache key for an organization user . @@ -45,8 +48,37 @@ public static class EventIntegrationsCacheConstants /// /// A cache key for the user. /// - public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId) - { - return $"OrganizationUserUserDetails:{organizationId:N}:{userId:N}"; - } + public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId) => + $"OrganizationUserUserDetails:{organizationId:N}:{userId:N}"; + + /// + /// Builds a deterministic cache key for an organization's integration configuration details + /// . + /// + /// The unique identifier of the organization to which the user belongs. + /// The of the integration. + /// The of the event configured. Can be null to apply to all events. + /// + /// A cache key for the configuration details. + /// + public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType? eventType + ) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}"; + + /// + /// Builds a deterministic tag for tagging an organization's integration configuration details. This tag is then + /// used to tag all of the that result from this + /// integration, which allows us to remove all relevant entries when an integration is changed or removed. + /// + /// The unique identifier of the organization to which the user belongs. + /// The of the integration. + /// + /// A cache tag to use for the configuration details. + /// + public static string BuildCacheTagForOrganizationIntegration( + Guid organizationId, + IntegrationType integrationType + ) => $"OrganizationIntegration:{organizationId:N}:{integrationType}"; } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index 005e93c6aa..af24e11a0e 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -20,10 +20,9 @@ public class OrganizationIntegrationConfigurationRepository : Repository> GetConfigurationDetailsAsync( - Guid organizationId, - IntegrationType integrationType, - EventType eventType) + public async Task> + GetManyByEventTypeOrganizationIdIntegrationType(EventType eventType, Guid organizationId, + IntegrationType integrationType) { using (var connection = new SqlConnection(ConnectionString)) { diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index fc391b958c..ff8f92fd91 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -17,16 +17,17 @@ public class OrganizationIntegrationConfigurationRepository : Repository context.OrganizationIntegrationConfigurations) { } - public async Task> GetConfigurationDetailsAsync( - Guid organizationId, - IntegrationType integrationType, - EventType eventType) + public async Task> + GetManyByEventTypeOrganizationIdIntegrationType(EventType eventType, Guid organizationId, + IntegrationType integrationType) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); var query = new OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery( - organizationId, eventType, integrationType + organizationId, + eventType, + integrationType ); return await query.Run(dbContext).ToListAsync(); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs index b4441c5084..421bb9407a 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs @@ -1,31 +1,21 @@ -#nullable enable - -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; -public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery : IQuery +public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery( + Guid organizationId, + EventType eventType, + IntegrationType integrationType) + : IQuery { - private readonly Guid _organizationId; - private readonly EventType _eventType; - private readonly IntegrationType _integrationType; - - public OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(Guid organizationId, EventType eventType, IntegrationType integrationType) - { - _organizationId = organizationId; - _eventType = eventType; - _integrationType = integrationType; - } - public IQueryable Run(DatabaseContext dbContext) { var query = from oic in dbContext.OrganizationIntegrationConfigurations - join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id into oioic - from oi in dbContext.OrganizationIntegrations - where oi.OrganizationId == _organizationId && - oi.Type == _integrationType && - oic.EventType == _eventType + join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id + where oi.OrganizationId == organizationId && + oi.Type == integrationType && + (oic.EventType == eventType || oic.EventType == null) select new OrganizationIntegrationConfigurationDetails() { Id = oic.Id, diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 587ddb65a4..b7fabc5b58 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -893,13 +893,11 @@ public static class ServiceCollectionExtensions integrationType: listenerConfiguration.IntegrationType, eventIntegrationPublisher: provider.GetRequiredService(), integrationFilterService: provider.GetRequiredService(), - configurationCache: provider.GetRequiredService(), cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), groupRepository: provider.GetRequiredService(), organizationRepository: provider.GetRequiredService(), - organizationUserRepository: provider.GetRequiredService(), - logger: provider.GetRequiredService>>() - ) + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) ); services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => @@ -941,10 +939,6 @@ public static class ServiceCollectionExtensions // Add common services services.AddDistributedCache(globalSettings); services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); - services.TryAddSingleton(); - services.TryAddSingleton(provider => - provider.GetRequiredService()); - services.AddHostedService(provider => provider.GetRequiredService()); services.TryAddSingleton(); services.TryAddKeyedSingleton("persistent"); @@ -1024,13 +1018,11 @@ public static class ServiceCollectionExtensions integrationType: listenerConfiguration.IntegrationType, eventIntegrationPublisher: provider.GetRequiredService(), integrationFilterService: provider.GetRequiredService(), - configurationCache: provider.GetRequiredService(), cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), groupRepository: provider.GetRequiredService(), organizationRepository: provider.GetRequiredService(), - organizationUserRepository: provider.GetRequiredService(), - logger: provider.GetRequiredService>>() - ) + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) ); services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql index 3240402916..7124be73fb 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql @@ -11,7 +11,7 @@ BEGIN FROM [dbo].[OrganizationIntegrationConfigurationDetailsView] oic WHERE - oic.[EventType] = @EventType + (oic.[EventType] = @EventType OR oic.[EventType] IS NULL) AND oic.[OrganizationId] = @OrganizationId AND diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index 73566cff89..235d597b12 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +#nullable enable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Repositories; @@ -8,6 +10,7 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -36,12 +39,16 @@ public class EventIntegrationHandlerTests private SutProvider> GetSutProvider( List configurations) { - var configurationCache = Substitute.For(); - configurationCache.GetConfigurationDetails(Arg.Any(), - IntegrationType.Webhook, Arg.Any()).Returns(configurations); + var cache = Substitute.For(); + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>() + ).Returns(configurations); return new SutProvider>() - .SetDependency(configurationCache) + .SetDependency(cache) .SetDependency(_eventIntegrationPublisher) .SetDependency(IntegrationType.Webhook) .SetDependency(_logger) @@ -173,6 +180,37 @@ public class EventIntegrationHandlerTests Assert.Null(context.ActingUser); } + [Theory, BitAutoData] + public async Task BuildContextAsync_ActingUserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails actingUser) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var cache = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.ActingUserId ??= Guid.NewGuid(); + organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( + eventMessage.OrganizationId.Value, + eventMessage.ActingUserId.Value).Returns(actingUser); + + // Capture the factory function passed to the cache + Func, CancellationToken, Task>? capturedFactory = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Do, CancellationToken, Task>>(f => capturedFactory = f) + ).Returns(actingUser); + + await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser); + + Assert.NotNull(capturedFactory); + var result = await capturedFactory(null!, CancellationToken.None); + + await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync( + eventMessage.OrganizationId.Value, + eventMessage.ActingUserId.Value); + Assert.Equal(actingUser, result); + } + [Theory, BitAutoData] public async Task BuildContextAsync_GroupIdPresent_UsesCache(EventMessage eventMessage, Group group) { @@ -211,6 +249,32 @@ public class EventIntegrationHandlerTests Assert.Null(context.Group); } + [Theory, BitAutoData] + public async Task BuildContextAsync_GroupFactory_CallsGroupRepository(EventMessage eventMessage, Group group) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup)); + var cache = sutProvider.GetDependency(); + var groupRepository = sutProvider.GetDependency(); + + eventMessage.GroupId ??= Guid.NewGuid(); + groupRepository.GetByIdAsync(eventMessage.GroupId.Value).Returns(group); + + // Capture the factory function passed to the cache + Func, CancellationToken, Task>? capturedFactory = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Do, CancellationToken, Task>>(f => capturedFactory = f) + ).Returns(group); + + await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup); + + Assert.NotNull(capturedFactory); + var result = await capturedFactory(null!, CancellationToken.None); + + await groupRepository.Received(1).GetByIdAsync(eventMessage.GroupId.Value); + Assert.Equal(group, result); + } + [Theory, BitAutoData] public async Task BuildContextAsync_OrganizationIdPresent_UsesCache(EventMessage eventMessage, Organization organization) { @@ -250,6 +314,32 @@ public class EventIntegrationHandlerTests Assert.Null(context.Organization); } + [Theory, BitAutoData] + public async Task BuildContextAsync_OrganizationFactory_CallsOrganizationRepository(EventMessage eventMessage, Organization organization) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var cache = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value).Returns(organization); + + // Capture the factory function passed to the cache + Func, CancellationToken, Task>? capturedFactory = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Do, CancellationToken, Task>>(f => capturedFactory = f) + ).Returns(organization); + + await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization); + + Assert.NotNull(capturedFactory); + var result = await capturedFactory(null!, CancellationToken.None); + + await organizationRepository.Received(1).GetByIdAsync(eventMessage.OrganizationId.Value); + Assert.Equal(organization, result); + } + [Theory, BitAutoData] public async Task BuildContextAsync_UserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails userDetails) { @@ -313,6 +403,38 @@ public class EventIntegrationHandlerTests Assert.Null(context.User); } + + [Theory, BitAutoData] + public async Task BuildContextAsync_UserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails userDetails) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var cache = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.UserId ??= Guid.NewGuid(); + organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( + eventMessage.OrganizationId.Value, + eventMessage.UserId.Value).Returns(userDetails); + + // Capture the factory function passed to the cache + Func, CancellationToken, Task>? capturedFactory = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Do, CancellationToken, Task>>(f => capturedFactory = f) + ).Returns(userDetails); + + await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser); + + Assert.NotNull(capturedFactory); + var result = await capturedFactory(null!, CancellationToken.None); + + await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync( + eventMessage.OrganizationId.Value, + eventMessage.UserId.Value); + Assert.Equal(userDetails, result); + } + [Theory, BitAutoData] public async Task BuildContextAsync_NoSpecialTokens_DoesNotCallAnyCache(EventMessage eventMessage) { @@ -344,6 +466,12 @@ public class EventIntegrationHandlerTests public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) { var sutProvider = GetSutProvider(NoConfigurations()); + var cache = sutProvider.GetDependency(); + cache.GetOrSetAsync>( + Arg.Any(), + Arg.Any>>>(), + Arg.Any() + ).Returns(NoConfigurations()); await sutProvider.Sut.HandleEventAsync(eventMessage); Assert.Empty(_eventIntegrationPublisher.ReceivedCalls()); @@ -362,8 +490,8 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage) { - var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); eventMessage.OrganizationId = _organizationId; + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -382,8 +510,8 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage) { - var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); eventMessage.OrganizationId = _organizationId; + var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -405,6 +533,7 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleEventAsync_FilterReturnsFalse_DoesNothing(EventMessage eventMessage) { + eventMessage.OrganizationId = _organizationId; var sutProvider = GetSutProvider(ValidFilterConfiguration()); sutProvider.GetDependency().EvaluateFilterGroup( Arg.Any(), Arg.Any()).Returns(false); @@ -416,10 +545,10 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleEventAsync_FilterReturnsTrue_PublishesIntegrationMessage(EventMessage eventMessage) { + eventMessage.OrganizationId = _organizationId; var sutProvider = GetSutProvider(ValidFilterConfiguration()); sutProvider.GetDependency().EvaluateFilterGroup( Arg.Any(), Arg.Any()).Returns(true); - eventMessage.OrganizationId = _organizationId; await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -435,6 +564,7 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleEventAsync_InvalidFilter_LogsErrorDoesNothing(EventMessage eventMessage) { + eventMessage.OrganizationId = _organizationId; var sutProvider = GetSutProvider(InvalidFilterConfiguration()); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -444,12 +574,13 @@ public class EventIntegrationHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any>()); + Arg.Any>()); } [Theory, BitAutoData] public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List eventMessages) { + eventMessages.ForEach(e => e.OrganizationId = _organizationId); var sutProvider = GetSutProvider(NoConfigurations()); await sutProvider.Sut.HandleManyEventsAsync(eventMessages); @@ -459,13 +590,14 @@ public class EventIntegrationHandlerTests [Theory, BitAutoData] public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessages(List eventMessages) { + eventMessages.ForEach(e => e.OrganizationId = _organizationId); var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); await sutProvider.Sut.HandleManyEventsAsync(eventMessages); foreach (var eventMessage in eventMessages) { - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage( + var expectedMessage = ExpectedMessage( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}" ); await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( @@ -477,13 +609,14 @@ public class EventIntegrationHandlerTests public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages( List eventMessages) { + eventMessages.ForEach(e => e.OrganizationId = _organizationId); var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); await sutProvider.Sut.HandleManyEventsAsync(eventMessages); foreach (var eventMessage in eventMessages) { - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage( + var expectedMessage = ExpectedMessage( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}" ); await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual( @@ -494,4 +627,84 @@ public class EventIntegrationHandlerTests expectedMessage, new[] { "MessageId", "OrganizationId" }))); } } + + [Theory, BitAutoData] + public async Task HandleEventAsync_CapturedFactories_CallConfigurationRepository(EventMessage eventMessage) + { + eventMessage.OrganizationId = _organizationId; + var sutProvider = GetSutProvider(NoConfigurations()); + var cache = sutProvider.GetDependency(); + var configurationRepository = sutProvider.GetDependency(); + + var configs = OneConfiguration(_templateBase); + + configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook).Returns(configs); + + // Capture the factory function - there will be 1 call that returns both specific and wildcard matches + Func>, CancellationToken, Task>>? capturedFactory = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Do>, CancellationToken, Task>>>(f + => capturedFactory = f), + options: Arg.Any(), + tags: Arg.Any>() + ).Returns(new List()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + // Verify factory was captured + Assert.NotNull(capturedFactory); + + // Execute the captured factory to trigger repository call + await capturedFactory(null!, CancellationToken.None); + + await configurationRepository.Received(1).GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_ConfigurationCacheOptions_SetsDurationToConstant(EventMessage eventMessage) + { + eventMessage.OrganizationId = _organizationId; + var sutProvider = GetSutProvider(NoConfigurations()); + var cache = sutProvider.GetDependency(); + + FusionCacheEntryOptions? capturedOption = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>, CancellationToken, Task>>>(), + options: Arg.Do(opt => capturedOption = opt), + tags: Arg.Any?>() + ).Returns(new List()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.NotNull(capturedOption); + Assert.Equal(EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails, + capturedOption.Duration); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_ConfigurationCache_AddsOrganizationIntegrationTag(EventMessage eventMessage) + { + eventMessage.OrganizationId = _organizationId; + var sutProvider = GetSutProvider(NoConfigurations()); + var cache = sutProvider.GetDependency(); + + IEnumerable? capturedTags = null; + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>, CancellationToken, Task>>>(), + options: Arg.Any(), + tags: Arg.Do>(t => capturedTags = t) + ).Returns(new List()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + var expectedTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + _organizationId, + IntegrationType.Webhook + ); + Assert.NotNull(capturedTags); + Assert.Contains(expectedTag, capturedTags); + } } diff --git a/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs deleted file mode 100644 index 4e87d13caf..0000000000 --- a/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -#nullable enable - -using System.Text.Json; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.Extensions.Logging; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.Services; - -[SutProviderCustomize] -public class IntegrationConfigurationDetailsCacheServiceTests -{ - private SutProvider GetSutProvider( - List configurations) - { - var configurationRepository = Substitute.For(); - configurationRepository.GetAllConfigurationDetailsAsync().Returns(configurations); - - return new SutProvider() - .SetDependency(configurationRepository) - .Create(); - } - - [Theory, BitAutoData] - public async Task GetConfigurationDetails_SpecificKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config) - { - config.EventType = EventType.Cipher_Created; - var sutProvider = GetSutProvider([config]); - await sutProvider.Sut.RefreshAsync(); - var result = sutProvider.Sut.GetConfigurationDetails( - config.OrganizationId, - config.IntegrationType, - EventType.Cipher_Created); - Assert.Single(result); - Assert.Same(config, result[0]); - } - - [Theory, BitAutoData] - public async Task GetConfigurationDetails_AllEventsKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config) - { - config.EventType = null; - var sutProvider = GetSutProvider([config]); - await sutProvider.Sut.RefreshAsync(); - var result = sutProvider.Sut.GetConfigurationDetails( - config.OrganizationId, - config.IntegrationType, - EventType.Cipher_Created); - Assert.Single(result); - Assert.Same(config, result[0]); - } - - [Theory, BitAutoData] - public async Task GetConfigurationDetails_BothSpecificAndAllEventsKeyExists_ReturnsExpectedList( - OrganizationIntegrationConfigurationDetails specificConfig, - OrganizationIntegrationConfigurationDetails allKeysConfig - ) - { - specificConfig.EventType = EventType.Cipher_Created; - allKeysConfig.EventType = null; - allKeysConfig.OrganizationId = specificConfig.OrganizationId; - allKeysConfig.IntegrationType = specificConfig.IntegrationType; - - var sutProvider = GetSutProvider([specificConfig, allKeysConfig]); - await sutProvider.Sut.RefreshAsync(); - var result = sutProvider.Sut.GetConfigurationDetails( - specificConfig.OrganizationId, - specificConfig.IntegrationType, - EventType.Cipher_Created); - Assert.Equal(2, result.Count); - Assert.Contains(result, r => r.Template == specificConfig.Template); - Assert.Contains(result, r => r.Template == allKeysConfig.Template); - } - - [Theory, BitAutoData] - public async Task GetConfigurationDetails_KeyMissing_ReturnsEmptyList(OrganizationIntegrationConfigurationDetails config) - { - var sutProvider = GetSutProvider([config]); - await sutProvider.Sut.RefreshAsync(); - var result = sutProvider.Sut.GetConfigurationDetails( - Guid.NewGuid(), - config.IntegrationType, - config.EventType ?? EventType.Cipher_Created); - Assert.Empty(result); - } - - - - [Theory, BitAutoData] - public async Task GetConfigurationDetails_ReturnsCachedValue_EvenIfRepositoryChanges(OrganizationIntegrationConfigurationDetails config) - { - var sutProvider = GetSutProvider([config]); - await sutProvider.Sut.RefreshAsync(); - - var newConfig = JsonSerializer.Deserialize(JsonSerializer.Serialize(config)); - Assert.NotNull(newConfig); - newConfig.Template = "Changed"; - sutProvider.GetDependency().GetAllConfigurationDetailsAsync() - .Returns([newConfig]); - - var result = sutProvider.Sut.GetConfigurationDetails( - config.OrganizationId, - config.IntegrationType, - config.EventType ?? EventType.Cipher_Created); - Assert.Single(result); - Assert.NotEqual("Changed", result[0].Template); // should not yet pick up change from repository - - await sutProvider.Sut.RefreshAsync(); // Pick up changes - - result = sutProvider.Sut.GetConfigurationDetails( - config.OrganizationId, - config.IntegrationType, - config.EventType ?? EventType.Cipher_Created); - Assert.Single(result); - Assert.Equal("Changed", result[0].Template); // Should have the new value - } - - [Theory, BitAutoData] - public async Task RefreshAsync_GroupsByCompositeKey(OrganizationIntegrationConfigurationDetails config1) - { - var config2 = JsonSerializer.Deserialize( - JsonSerializer.Serialize(config1))!; - config2.Template = "Another"; - - var sutProvider = GetSutProvider([config1, config2]); - await sutProvider.Sut.RefreshAsync(); - - var results = sutProvider.Sut.GetConfigurationDetails( - config1.OrganizationId, - config1.IntegrationType, - config1.EventType ?? EventType.Cipher_Created); - - Assert.Equal(2, results.Count); - Assert.Contains(results, r => r.Template == config1.Template); - Assert.Contains(results, r => r.Template == config2.Template); - } - - [Theory, BitAutoData] - public async Task RefreshAsync_LogsInformationOnSuccess(OrganizationIntegrationConfigurationDetails config) - { - var sutProvider = GetSutProvider([config]); - await sutProvider.Sut.RefreshAsync(); - - sutProvider.GetDependency>().Received().Log( - LogLevel.Information, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Refreshed successfully")), - null, - Arg.Any>()); - } - - [Fact] - public async Task RefreshAsync_OnException_LogsError() - { - var sutProvider = GetSutProvider([]); - sutProvider.GetDependency().GetAllConfigurationDetailsAsync() - .Throws(new Exception("Database failure")); - await sutProvider.Sut.RefreshAsync(); - - sutProvider.GetDependency>().Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Refresh failed")), - Arg.Any(), - Arg.Any>()); - } -} diff --git a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs index 051801e505..f6084c9209 100644 --- a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs +++ b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Utilities; +using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -11,8 +12,12 @@ public class EventIntegrationsCacheConstantsTests { var expected = $"Group:{groupId:N}"; var key = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId); + var keyWithDifferentGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(Guid.NewGuid()); + var keyWithSameGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId); Assert.Equal(expected, key); + Assert.NotEqual(key, keyWithDifferentGroup); + Assert.Equal(key, keyWithSameGroup); } [Theory, BitAutoData] @@ -20,8 +25,69 @@ public class EventIntegrationsCacheConstantsTests { var expected = $"Organization:{orgId:N}"; var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId); + var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(Guid.NewGuid()); + var keyWithSameOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId); Assert.Equal(expected, key); + Assert.NotEqual(key, keyWithDifferentOrg); + Assert.Equal(key, keyWithSameOrg); + } + + [Theory, BitAutoData] + public void BuildCacheKeyForOrganizationIntegrationConfigurationDetails_ReturnsExpectedKey(Guid orgId) + { + var integrationType = IntegrationType.Hec; + + var expectedWithEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:User_LoggedIn"; + var keyWithEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, integrationType, EventType.User_LoggedIn); + var keyWithDifferentEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, integrationType, EventType.Cipher_Created); + var keyWithDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, IntegrationType.Webhook, EventType.User_LoggedIn); + var keyWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + Guid.NewGuid(), integrationType, EventType.User_LoggedIn); + var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, integrationType, EventType.User_LoggedIn); + + Assert.Equal(expectedWithEvent, keyWithEvent); + Assert.NotEqual(keyWithEvent, keyWithDifferentEvent); + Assert.NotEqual(keyWithEvent, keyWithDifferentIntegration); + Assert.NotEqual(keyWithEvent, keyWithDifferentOrganization); + Assert.Equal(keyWithEvent, keyWithSameDetails); + + var expectedWithNullEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:"; + var keyWithNullEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, integrationType, null); + var keyWithNullEventDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + orgId, IntegrationType.Webhook, null); + var keyWithNullEventDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + Guid.NewGuid(), integrationType, null); + + Assert.Equal(expectedWithNullEvent, keyWithNullEvent); + Assert.NotEqual(keyWithEvent, keyWithNullEvent); + Assert.NotEqual(keyWithNullEvent, keyWithDifferentEvent); + Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentIntegration); + Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentOrganization); + } + + [Theory, BitAutoData] + public void BuildCacheTagForOrganizationIntegration_ReturnsExpectedKey(Guid orgId) + { + var expected = $"OrganizationIntegration:{orgId:N}:Hec"; + var tag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + orgId, IntegrationType.Hec); + var tagWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + Guid.NewGuid(), IntegrationType.Hec); + var tagWithDifferentIntegrationType = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + orgId, IntegrationType.Webhook); + var tagWithSameDetails = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + orgId, IntegrationType.Hec); + + Assert.Equal(expected, tag); + Assert.NotEqual(tag, tagWithDifferentOrganization); + Assert.NotEqual(tag, tagWithDifferentIntegrationType); + Assert.Equal(tag, tagWithSameDetails); } [Theory, BitAutoData] @@ -29,8 +95,14 @@ public class EventIntegrationsCacheConstantsTests { var expected = $"OrganizationUserUserDetails:{orgId:N}:{userId:N}"; var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId); + var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(Guid.NewGuid(), userId); + var keyWithDifferentUser = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, Guid.NewGuid()); + var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId); Assert.Equal(expected, key); + Assert.NotEqual(key, keyWithDifferentOrg); + Assert.NotEqual(key, keyWithDifferentUser); + Assert.Equal(key, keyWithSameDetails); } [Fact] @@ -38,4 +110,13 @@ public class EventIntegrationsCacheConstantsTests { Assert.Equal("EventIntegrations", EventIntegrationsCacheConstants.CacheName); } + + [Fact] + public void DurationForOrganizationIntegrationConfigurationDetails_ReturnsExpected() + { + Assert.Equal( + TimeSpan.FromDays(1), + EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails + ); + } } diff --git a/util/Migrator/DbScripts/2025-12-05_00_UpdateOrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql b/util/Migrator/DbScripts/2025-12-05_00_UpdateOrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql new file mode 100644 index 0000000000..727c961e31 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-05_00_UpdateOrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql @@ -0,0 +1,20 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType] + @EventType SMALLINT, + @OrganizationId UNIQUEIDENTIFIER, + @IntegrationType SMALLINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic + WHERE + (oic.[EventType] = @EventType OR oic.[EventType] IS NULL) + AND + oic.[OrganizationId] = @OrganizationId + AND + oic.[IntegrationType] = @IntegrationType +END +GO