diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 0b7fe8dffe..f172a23529 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,8 +1,8 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class OrganizationIntegrationConfigurationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository, - IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller + ICreateOrganizationIntegrationConfigurationCommand createCommand, + IUpdateOrganizationIntegrationConfigurationCommand updateCommand, + IDeleteOrganizationIntegrationConfigurationCommand deleteCommand, + IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync( @@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); + var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId); return configurations .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) .ToList(); @@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); - var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); - return new OrganizationIntegrationConfigurationResponseModel(configuration); + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var created = await createCommand.CreateAsync(organizationId, integrationId, configuration); + + return new OrganizationIntegrationConfigurationResponseModel(created); } [HttpPut("{configurationId:guid}")] @@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration); - var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); - await integrationConfigurationRepository.ReplaceAsync(newConfiguration); - - return new OrganizationIntegrationConfigurationResponseModel(newConfiguration); + return new OrganizationIntegrationConfigurationResponseModel(updated); } [HttpDelete("{configurationId:guid}")] @@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } - - await integrationConfigurationRepository.DeleteAsync(configuration); + await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId); } [HttpPost("{configurationId:guid}/delete")] diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 8581c4ae1f..9341392d68 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,6 +1,4 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; @@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel public string? Template { get; set; } - public bool IsValidForType(IntegrationType integrationType) - { - switch (integrationType) - { - case IntegrationType.CloudBillingSync or IntegrationType.Scim: - return false; - case IntegrationType.Slack: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Webhook: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Hec: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Datadog: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Teams: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - default: - return false; - - } - } - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) { return new OrganizationIntegrationConfiguration() @@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel Template = Template }; } - - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration) - { - currentConfiguration.Configuration = Configuration; - currentConfiguration.EventType = EventType; - currentConfiguration.Filters = Filters; - currentConfiguration.Template = Template; - - return currentConfiguration; - } - - private bool IsConfigurationValid() - { - if (string.IsNullOrWhiteSpace(Configuration)) - { - return false; - } - - try - { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; - } - catch - { - return false; - } - } - - private bool IsFiltersValid() - { - if (Filters is null) - { - return true; - } - - try - { - var filters = JsonSerializer.Deserialize(Filters); - return filters is not null; - } - catch - { - return false; - } - } } diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index 9ebe09ebcc..6b848be11f 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ -using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -20,8 +23,12 @@ public static class EventIntegrationsServiceCollectionExtensions // This is idempotent for the same named cache, so it's safe to call. services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + // Add Validator + services.TryAddSingleton(); + // Add all commands/queries services.AddOrganizationIntegrationCommandsQueries(); + services.AddOrganizationIntegrationConfigurationCommandsQueries(); return services; } @@ -35,4 +42,14 @@ public static class EventIntegrationsServiceCollectionExtensions return services; } + + internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } } diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..cb3ce8b9ea --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,64 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for creating organization integration configurations with validation and cache invalidation support. +/// +public class CreateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : ICreateOrganizationIntegrationConfigurationCommand +{ + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, configuration)) + { + throw new BadRequestException( + $"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + var created = await configurationRepository.CreateAsync(configuration); + + // Invalidate the cached configuration details + // Even though this is a new record, the cache could hold a stale empty list for this + if (created.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: created.EventType.Value + )); + } + + return created; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..78768fd0d4 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for deleting organization integration configurations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationConfigurationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + await configurationRepository.DeleteAsync(configuration); + + if (configuration.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + } + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..a2078c3c98 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,29 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Query implementation for retrieving organization integration configurations. +/// +public class GetOrganizationIntegrationConfigurationsQuery( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IGetOrganizationIntegrationConfigurationsQuery +{ + public async Task> GetManyByIntegrationAsync( + Guid organizationId, + Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations.ToList(); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..140cc79d1a --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for creating organization integration configurations. +/// +public interface ICreateOrganizationIntegrationConfigurationCommand +{ + /// + /// Creates a new configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The configuration to create. + /// The created configuration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3970676d40 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for deleting organization integration configurations. +/// +public interface IDeleteOrganizationIntegrationConfigurationCommand +{ + /// + /// Deletes a configuration from an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to delete. + /// + /// Thrown when the integration or configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..2bf806c458 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Query interface for retrieving organization integration configurations. +/// +public interface IGetOrganizationIntegrationConfigurationsQuery +{ + /// + /// Retrieves all configurations for a specific organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// A list of configurations associated with the integration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task> GetManyByIntegrationAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3e60a0af07 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for updating organization integration configurations. +/// +public interface IUpdateOrganizationIntegrationConfigurationCommand +{ + /// + /// Updates an existing configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to update. + /// The updated configuration data. + /// The updated configuration. + /// + /// Thrown when the integration or the configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task UpdateAsync(Guid organizationId, Guid integrationId, Guid configurationId, OrganizationIntegrationConfiguration updatedConfiguration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..f619e2ddf2 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,82 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for updating organization integration configurations with validation and cache invalidation support. +/// +public class UpdateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : IUpdateOrganizationIntegrationConfigurationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, updatedConfiguration)) + { + throw new BadRequestException($"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + updatedConfiguration.Id = configuration.Id; + updatedConfiguration.CreationDate = configuration.CreationDate; + await configurationRepository.ReplaceAsync(updatedConfiguration); + + // If either old or new EventType is null (wildcard), invalidate all cached results + // for the specific integration + if (configuration.EventType == null || updatedConfiguration.EventType == null) + { + // Wildcard involved - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedConfiguration; + } + + // Both are specific event types - invalidate specific cache entries + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + + // If event type changed, also clear the new event type's cache + if (configuration.EventType != updatedConfiguration.EventType) + { + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: updatedConfiguration.EventType.Value + )); + } + + return updatedConfiguration; + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..48346cbae7 --- /dev/null +++ b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public interface IOrganizationIntegrationConfigurationValidator +{ + /// + /// Validates that the configuration is valid for the given integration type. The configuration must + /// include a Configuration that is valid for the type, valid Filters, and a non-empty Template + /// to pass validation. + /// + /// The type of integration + /// The OrganizationIntegrationConfiguration to validate + /// True if valid, false otherwise + bool ValidateConfiguration(IntegrationType integrationType, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..2769565675 --- /dev/null +++ b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public class OrganizationIntegrationConfigurationValidator : IOrganizationIntegrationConfigurationValidator +{ + public bool ValidateConfiguration(IntegrationType integrationType, + OrganizationIntegrationConfiguration configuration) + { + // Validate template is present + if (string.IsNullOrWhiteSpace(configuration.Template)) + { + return false; + } + // If Filters are present, they must be valid + if (!IsFiltersValid(configuration.Filters)) + { + return false; + } + + switch (integrationType) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + return false; + case IntegrationType.Slack: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Webhook: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Hec: + case IntegrationType.Datadog: + case IntegrationType.Teams: + return configuration.Configuration is null; + default: + return false; + } + } + + private static bool IsConfigurationValid(string? configuration) + { + if (string.IsNullOrWhiteSpace(configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(configuration); + return config is not null; + } + catch + { + return false; + } + } + + private static bool IsFiltersValid(string? filters) + { + if (filters is null) + { + return true; + } + + try + { + var filterGroup = JsonSerializer.Deserialize(filters); + return filterGroup is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Core/Utilities/EventIntegrationsCacheConstants.cs b/src/Core/Utilities/EventIntegrationsCacheConstants.cs index 6bd90c797d..19cc3f949c 100644 --- a/src/Core/Utilities/EventIntegrationsCacheConstants.cs +++ b/src/Core/Utilities/EventIntegrationsCacheConstants.cs @@ -55,16 +55,16 @@ public static class EventIntegrationsCacheConstants /// Builds a deterministic cache key for an organization's integration configuration details /// . /// - /// The unique identifier of the organization to which the user belongs. + /// The unique identifier of the organization. /// The of the integration. - /// The of the event configured. Can be null to apply to all events. + /// The specific of the event configured. /// /// A cache key for the configuration details. /// public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails( Guid organizationId, IntegrationType integrationType, - EventType? eventType + EventType eventType ) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}"; /// diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 9ab626d3f0..6e1dadb92f 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -1,18 +1,14 @@ -using System.Text.Json; -using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.Context; -using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Api.Test.AdminConsole.Controllers; @@ -25,823 +21,191 @@ public class OrganizationIntegrationsConfigurationControllerTests public async Task DeleteAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + Guid integrationId, + Guid configurationId) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegrationConfiguration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegrationConfiguration); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId, configurationId); } [Theory, BitAutoData] + [Obsolete("Obsolete")] public async Task PostDeleteAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegrationConfiguration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegrationConfiguration); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - organizationIntegration.OrganizationId = organizationId; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); - } + await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId, configurationId); - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationConfigDoesNotBelongToIntegration_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = Guid.Empty; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId, configurationId); } [Theory, BitAutoData] public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, - Guid organizationId) + Guid organizationId, + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); } [Theory, BitAutoData] public async Task GetAsync_ConfigurationsExist_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - List organizationIntegrationConfigurations) + Guid integrationId, + List configurations) { - organizationIntegration.OrganizationId = organizationId; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetManyByIntegrationAsync(Arg.Any()) - .Returns(organizationIntegrationConfigurations); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(organizationId, integrationId) + .Returns(configurations); + + var result = await sutProvider.Sut.GetAsync(organizationId, integrationId); - var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); Assert.NotNull(result); - Assert.Equal(organizationIntegrationConfigurations.Count, result.Count); + Assert.Equal(configurations.Count, result.Count); Assert.All(result, r => Assert.IsType(r)); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetManyByIntegrationAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationId, integrationId); } [Theory, BitAutoData] public async Task GetAsync_NoConfigurationsExist_ReturnsEmptyList( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration) + Guid integrationId) { - organizationIntegration.OrganizationId = organizationId; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetManyByIntegrationAsync(Arg.Any()) + sutProvider.GetDependency() + .GetManyByIntegrationAsync(organizationId, integrationId) .Returns([]); - var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); + var result = await sutProvider.Sut.GetAsync(organizationId, integrationId); + Assert.NotNull(result); Assert.Empty(result); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetManyByIntegrationAsync(organizationIntegration.Id); - } - - [Theory, BitAutoData] - public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); - } - - [Theory, BitAutoData] - public async Task GetAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id)); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationId, integrationId); } [Theory, BitAutoData] public async Task GetAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, - Guid organizationId) + Guid organizationId, + Guid integrationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetAsync(organizationId, integrationId)); } [Theory, BitAutoData] - public async Task PostAsync_AllParamsProvided_Slack_Succeeds( + public async Task PostAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + Guid integrationId, + OrganizationIntegrationConfiguration configuration, OrganizationIntegrationConfigurationRequestModel model) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + sutProvider.GetDependency() + .CreateAsync(organizationId, integrationId, Arg.Any()) + .Returns(configuration); - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, integrationId, model); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(organizationId, integrationId, Arg.Any()); Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] - public async Task PostAsync_AllParamsProvided_Webhook_Succeeds( + public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); - Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); - } - - [Theory, BitAutoData] - public async Task PostAsync_OnlyUrlProvided_Webhook_Succeeds( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); - Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationTypeCloudBillingSync_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.CloudBillingSync; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationTypeScim_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Scim; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task PostAsync_InvalidConfiguration_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - model.Configuration = null; - model.Template = "Template String"; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_InvalidTemplate_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + Guid integrationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, Guid.Empty, new OrganizationIntegrationConfigurationRequestModel())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(organizationId, integrationId, new OrganizationIntegrationConfigurationRequestModel())); } [Theory, BitAutoData] - public async Task UpdateAsync_AllParamsProvided_Slack_Succeeds( + public async Task UpdateAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration configuration, OrganizationIntegrationConfigurationRequestModel model) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); + sutProvider.GetDependency() + .UpdateAsync(organizationId, integrationId, configurationId, Arg.Any()) + .Returns(configuration); - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); + var updateResponse = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, model); + + await sutProvider.GetDependency().Received(1) + .UpdateAsync(organizationId, integrationId, configurationId, Arg.Any()); Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); } - [Theory, BitAutoData] - public async Task UpdateAsync_AllParamsProvided_Webhook_Succeeds( + public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); - - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); - Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_OnlyUrlProvided_Webhook_Succeeds( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); - - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); - Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - Guid.Empty, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - Guid.Empty, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_InvalidConfiguration_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - model.Configuration = null; - model.Template = "Template String"; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_InvalidTemplate_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - Guid.Empty, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, new OrganizationIntegrationConfigurationRequestModel())); } } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs deleted file mode 100644 index 8a75db9da8..0000000000 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System.Text.Json; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Enums; -using Xunit; - -namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; - -public class OrganizationIntegrationConfigurationRequestModelTests -{ - [Fact] - public void IsValidForType_CloudBillingSyncIntegration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.CloudBillingSync)); - } - - [Theory] - [InlineData(data: null)] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Theory] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Fact] - public void IsValidForType_NullConfiguration_ReturnsTrue() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = null, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.True(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Theory] - [InlineData(data: null)] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template) - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( - Uri: new Uri("https://localhost"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = template - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Fact] - public void IsValidForType_InvalidJsonConfiguration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{not valid json}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - - [Fact] - public void IsValidForType_InvalidJsonFilters_ReturnsFalse() - { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = "{Not valid json", - Template = "template" - }; - - Assert.False(model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ScimIntegration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Scim)); - } - - [Fact] - public void IsValidForType_ValidSlackConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new SlackIntegrationConfiguration(ChannelId: "C12345")); - - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Slack)); - } - - [Fact] - public void IsValidForType_ValidSlackConfigurationWithFilters_ReturnsTrue() - { - var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")); - var filters = JsonSerializer.Serialize(new IntegrationFilterGroup() - { - AndOperator = true, - Rules = [ - new IntegrationFilterRule() - { - Operation = IntegrationFilterOperation.Equals, - Property = "CollectionId", - Value = Guid.NewGuid() - } - ], - Groups = [] - }); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = filters, - Template = "template" - }; - - Assert.True(model.IsValidForType(IntegrationType.Slack)); - } - - [Fact] - public void IsValidForType_ValidNoAuthWebhookConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( - Uri: new Uri("https://localhost"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ValidWebhookConfigurationWithFilters_ReturnsTrue() - { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( - Uri: new Uri("https://example.com"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var filters = JsonSerializer.Serialize(new IntegrationFilterGroup() - { - AndOperator = true, - Rules = [ - new IntegrationFilterRule() - { - Operation = IntegrationFilterOperation.Equals, - Property = "CollectionId", - Value = Guid.NewGuid() - } - ], - Groups = [] - }); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = filters, - Template = "template" - }; - - Assert.True(model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_UnknownIntegrationType_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - var unknownType = (IntegrationType)999; - - Assert.False(condition: model.IsValidForType(unknownType)); - } -} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index f69a61a322..4711426677 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Services; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -32,6 +34,7 @@ public class EventIntegrationServiceCollectionExtensionsTests // Mock required repository dependencies for commands _services.TryAddScoped(_ => Substitute.For()); + _services.TryAddScoped(_ => Substitute.For()); _services.TryAddScoped(_ => Substitute.For()); } @@ -45,6 +48,9 @@ public class EventIntegrationServiceCollectionExtensionsTests var cache = provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName); Assert.NotNull(cache); + var validator = provider.GetRequiredService(); + Assert.NotNull(validator); + using var scope = provider.CreateScope(); var sp = scope.ServiceProvider; @@ -52,6 +58,11 @@ public class EventIntegrationServiceCollectionExtensionsTests Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); + + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); } [Fact] @@ -61,8 +72,11 @@ public class EventIntegrationServiceCollectionExtensionsTests var createIntegrationDescriptor = _services.First(s => s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)); + var createConfigDescriptor = _services.First(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)); Assert.Equal(ServiceLifetime.Scoped, createIntegrationDescriptor.Lifetime); + Assert.Equal(ServiceLifetime.Scoped, createConfigDescriptor.Lifetime); } [Fact] @@ -117,7 +131,7 @@ public class EventIntegrationServiceCollectionExtensionsTests _services.AddEventIntegrationsCommandsQueries(_globalSettings); var createConfigCmdDescriptors = _services.Where(s => - s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList(); + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)).ToList(); Assert.Single(createConfigCmdDescriptors); var updateIntegrationCmdDescriptors = _services.Where(s => @@ -148,6 +162,29 @@ public class EventIntegrationServiceCollectionExtensionsTests Assert.Single(createCmdDescriptors); } + [Fact] + public void AddOrganizationIntegrationConfigurationCommandsQueries_RegistersAllConfigurationServices() + { + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + Assert.Contains(_services, s => s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IUpdateOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IDeleteOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IGetOrganizationIntegrationConfigurationsQuery)); + } + + [Fact] + public void AddOrganizationIntegrationConfigurationCommandsQueries_MultipleCalls_IsIdempotent() + { + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + var createCmdDescriptors = _services.Where(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)).ToList(); + Assert.Single(createCmdDescriptors); + } + private static GlobalSettings CreateGlobalSettings(Dictionary data) { var config = new ConfigurationBuilder() diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..c6c8a44955 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,179 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class CreateOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_Success_CreatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .CreateAsync(configuration) + .Returns(configuration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .CreateAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + configuration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(configuration, result); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WildcardSuccess_CreatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .CreateAsync(configuration) + .Returns(configuration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .CreateAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + Assert.Equal(configuration, result); + } + + [Theory, BitAutoData] + public async Task CreateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAsync_ValidationFails_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(false); + + integration.Id = integrationId; + integration.OrganizationId = organizationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.Template = "template"; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..3b12f4bd88 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,211 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class DeleteOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_Success_DeletesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + configuration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WildcardSuccess_DeletesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns((OrganizationIntegrationConfiguration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ConfigurationDoesNotBelongToIntegration_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = Guid.NewGuid(); // Different integration + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs new file mode 100644 index 0000000000..18541df53e --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs @@ -0,0 +1,101 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class GetOrganizationIntegrationConfigurationsQueryTests +{ + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_Success_ReturnsConfigurations( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + List configurations) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(integrationId) + .Returns(configurations); + + var result = await sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(integrationId); + Assert.Equal(configurations.Count, result.Count); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_NoConfigurations_ReturnsEmptyList( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(integrationId) + .Returns([]); + + var result = await sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId); + + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetManyByIntegrationAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetManyByIntegrationAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..c2eeefc087 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,390 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class UpdateOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_Success_UpdatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + existingConfiguration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WildcardSuccess_UpdatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = null; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedEventType_UpdatesConfigurationAndInvalidatesCacheForBothTypes( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.EventType = EventType.Cipher_Created; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + existingConfiguration.EventType.Value)); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + updatedConfiguration.EventType.Value)); + // Verify RemoveByTagAsync was NOT called since both are specific event types + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns((OrganizationIntegrationConfiguration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigurationDoesNotBelongToIntegration_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = Guid.NewGuid(); // Different integration + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ValidationFails_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(false); + + integration.Id = integrationId; + integration.OrganizationId = organizationId; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.Template = "template"; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedFromWildcardToSpecific_InvalidatesAllCaches( + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration, + SutProvider sutProvider) + { + integration.OrganizationId = organizationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = null; // Wildcard + updatedConfiguration.EventType = EventType.User_LoggedIn; // Specific + + sutProvider.GetDependency() + .GetByIdAsync(integrationId).Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(existingConfiguration.Id).Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, existingConfiguration.Id, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedFromSpecificToWildcard_InvalidatesAllCaches( + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration, + SutProvider sutProvider) + { + integration.OrganizationId = organizationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; // Specific + updatedConfiguration.EventType = null; // Wildcard + + sutProvider.GetDependency() + .GetByIdAsync(integrationId).Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(existingConfiguration.Id).Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, existingConfiguration.Id, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs new file mode 100644 index 0000000000..1154ad8025 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs @@ -0,0 +1,244 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Services; + +public class OrganizationIntegrationConfigurationValidatorTests +{ + private readonly OrganizationIntegrationConfigurationValidator _sut = new(); + + [Fact] + public void ValidateConfiguration_CloudBillingSyncIntegration_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.CloudBillingSync, configuration)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateConfiguration_EmptyTemplate_ReturnsFalse(string? template) + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration(ChannelId: "C12345")), + Template = template + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Slack, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))), + Template = template + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, config2)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateConfiguration_EmptyNonNullConfiguration_ReturnsFalse(string? config) + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Hec, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Datadog, config2)); + + var config3 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Teams, config3)); + } + + [Fact] + public void ValidateConfiguration_NullConfiguration_ReturnsTrue() + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Hec, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Datadog, config2)); + + var config3 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Teams, config3)); + } + + [Fact] + public void ValidateConfiguration_InvalidJsonConfiguration_ReturnsFalse() + { + var config = new OrganizationIntegrationConfiguration + { + Configuration = "{not valid json}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Slack, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Hec, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Datadog, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Teams, config)); + } + + [Fact] + public void ValidateConfiguration_InvalidJsonFilters_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))), + Template = "template", + Filters = "{Not valid json}" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ScimIntegration_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Scim, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidSlackConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration(ChannelId: "C12345")), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Slack, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidSlackConfigurationWithFilters_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")), + Template = "template", + Filters = JsonSerializer.Serialize(new IntegrationFilterGroup() + { + AndOperator = true, + Rules = [ + new IntegrationFilterRule() + { + Operation = IntegrationFilterOperation.Equals, + Property = "CollectionId", + Value = Guid.NewGuid() + } + ], + Groups = [] + }) + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Slack, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidNoAuthWebhookConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidWebhookConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidWebhookConfigurationWithFilters_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( + Uri: new Uri("https://example.com"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")), + Template = "template", + Filters = JsonSerializer.Serialize(new IntegrationFilterGroup() + { + AndOperator = true, + Rules = [ + new IntegrationFilterRule() + { + Operation = IntegrationFilterOperation.Equals, + Property = "CollectionId", + Value = Guid.NewGuid() + } + ], + Groups = [] + }) + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_UnknownIntegrationType_ReturnsFalse() + { + var unknownType = (IntegrationType)999; + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(unknownType, configuration)); + } +} diff --git a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs index f6084c9209..a87392c2c1 100644 --- a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs +++ b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs @@ -55,20 +55,6 @@ public class EventIntegrationsCacheConstantsTests 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]