diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 181811e892..b82fe3dfa8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -1,8 +1,8 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class OrganizationIntegrationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository) : Controller + ICreateOrganizationIntegrationCommand createCommand, + IUpdateOrganizationIntegrationCommand updateCommand, + IDeleteOrganizationIntegrationCommand deleteCommand, + IGetOrganizationIntegrationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync(Guid organizationId) @@ -22,7 +25,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integrations = await getQuery.GetManyByOrganizationAsync(organizationId); return integrations .Select(integration => new OrganizationIntegrationResponseModel(integration)) .ToList(); @@ -36,8 +39,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId)); - return new OrganizationIntegrationResponseModel(integration); + var integration = model.ToOrganizationIntegration(organizationId); + var created = await createCommand.CreateAsync(integration); + + return new OrganizationIntegrationResponseModel(created); } [HttpPut("{integrationId:guid}")] @@ -48,14 +53,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } + var integration = model.ToOrganizationIntegration(organizationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration); - await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration)); - return new OrganizationIntegrationResponseModel(integration); + return new OrganizationIntegrationResponseModel(updated); } [HttpDelete("{integrationId:guid}")] @@ -66,13 +67,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - - await integrationRepository.DeleteAsync(integration); + await deleteCommand.DeleteAsync(organizationId, integrationId); } [HttpPost("{integrationId:guid}/delete")] diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 85fef9cd87..bdbc2f8edc 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -226,7 +226,8 @@ public class Startup services.AddHostedService(); } - // Add Slack / Teams Services for OAuth API requests - if configured + // Add Event Integrations services + services.AddEventIntegrationsCommandsQueries(globalSettings); services.AddSlackService(globalSettings); services.AddTeamsService(globalSettings); } diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs new file mode 100644 index 0000000000..9ebe09ebcc --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class EventIntegrationsServiceCollectionExtensions +{ + /// + /// Adds all event integrations commands, queries, and required cache infrastructure. + /// This method is idempotent and can be called multiple times safely. + /// + public static IServiceCollection AddEventIntegrationsCommandsQueries( + this IServiceCollection services, + GlobalSettings globalSettings) + { + // Ensure cache is registered first - commands depend on this keyed cache. + // This is idempotent for the same named cache, so it's safe to call. + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + + // Add all commands/queries + services.AddOrganizationIntegrationCommandsQueries(); + + return services; + } + + internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..376451977c --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs @@ -0,0 +1,38 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations; + +/// +/// Command implementation for creating organization integrations with cache invalidation support. +/// +public class CreateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : ICreateOrganizationIntegrationCommand +{ + public async Task CreateAsync(OrganizationIntegration integration) + { + var existingIntegrations = await integrationRepository + .GetManyByOrganizationAsync(integration.OrganizationId); + if (existingIntegrations.Any(i => i.Type == integration.Type)) + { + throw new BadRequestException("An integration of this type already exists for this organization."); + } + + var created = await integrationRepository.CreateAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: integration.OrganizationId, + integrationType: integration.Type + )); + + return created; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..614693cd82 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,33 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations; + +/// +/// Command implementation for deleting organization integrations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await integrationRepository.DeleteAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..f7bbaadb4a --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; + +/// +/// Query implementation for retrieving organization integrations. +/// +public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository) + : IGetOrganizationIntegrationsQuery +{ + public async Task> GetManyByOrganizationAsync(Guid organizationId) + { + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + return integrations.ToList(); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..e7b79eab13 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for creating an OrganizationIntegration. +/// +public interface ICreateOrganizationIntegrationCommand +{ + /// + /// Creates a new organization integration. + /// + /// The OrganizationIntegration to create. + /// The created OrganizationIntegration. + /// Thrown when an integration + /// of the same type already exists for the organization. + Task CreateAsync(OrganizationIntegration integration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..be22b4e482 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for deleting organization integrations. +/// +public interface IDeleteOrganizationIntegrationCommand +{ + /// + /// Deletes an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to delete. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task DeleteAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..8cdea7f301 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Query interface for retrieving organization integrations. +/// +public interface IGetOrganizationIntegrationsQuery +{ + /// + /// Retrieves all organization integrations for a specific organization. + /// + /// The unique identifier of the organization. + /// A list of organization integrations associated with the organization. + Task> GetManyByOrganizationAsync(Guid organizationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..f40086600d --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for updating organization integrations. +/// +public interface IUpdateOrganizationIntegrationCommand +{ + /// + /// Updates an existing organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to update. + /// The updated organization integration data. + /// The updated organization integration. + /// Thrown when the integration does not exist, + /// does not belong to the specified organization, or the integration type does not match. + Task UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..12a8620926 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,45 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations; + +/// +/// Command implementation for updating organization integrations with cache invalidation support. +/// +public class UpdateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : IUpdateOrganizationIntegrationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegration updatedIntegration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || + integration.OrganizationId != organizationId || + integration.Type != updatedIntegration.Type) + { + throw new NotFoundException(); + } + + updatedIntegration.Id = integration.Id; + updatedIntegration.OrganizationId = integration.OrganizationId; + updatedIntegration.CreationDate = integration.CreationDate; + await integrationRepository.ReplaceAsync(updatedIntegration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedIntegration; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index a1d7793d37..f9de5b9778 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -296,7 +296,7 @@ graph TD ## Caching To reduce database load and improve performance, event integrations uses its own named extended cache (see -the [README in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/README.md#extended-cache) +[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md) for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. @@ -335,7 +335,8 @@ rather than using a string literal (i.e. "EventIntegrations") in code. - There are two places in the code that are both aware of the tagging functionality - The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache to store the entry with the tag when it successfully loads from the repository. - - The `OrganizationIntegrationController` needs to use the tag to remove all the tagged entries when and admin + - The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and + `DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin creates, updates, or deletes an integration. - To ensure both places are synchronized on how to tag entries, they both use `EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag. diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs index 335859e0c4..c9131f3505 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs @@ -2,15 +2,14 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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; @@ -19,7 +18,7 @@ namespace Bit.Api.Test.AdminConsole.Controllers; [SutProviderCustomize] public class OrganizationIntegrationControllerTests { - private OrganizationIntegrationRequestModel _webhookRequestModel = new OrganizationIntegrationRequestModel() + private readonly OrganizationIntegrationRequestModel _webhookRequestModel = new() { Configuration = null, Type = IntegrationType.Webhook @@ -48,13 +47,13 @@ public class OrganizationIntegrationControllerTests sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyByOrganizationAsync(organizationId) .Returns(integrations); var result = await sutProvider.Sut.GetAsync(organizationId); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .GetManyByOrganizationAsync(organizationId); Assert.Equal(integrations.Count, result.Count); @@ -70,7 +69,7 @@ public class OrganizationIntegrationControllerTests sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() + sutProvider.GetDependency() .GetManyByOrganizationAsync(organizationId) .Returns([]); @@ -80,199 +79,133 @@ public class OrganizationIntegrationControllerTests } [Theory, BitAutoData] - public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds( + public async Task CreateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + + var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(i => + i.OrganizationId == organizationId && + i.Type == IntegrationType.Webhook)); + Assert.IsType(response); + } + + [Theory, BitAutoData] + public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(callInfo => callInfo.Arg()); - var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); - Assert.IsType(response); - Assert.Equal(IntegrationType.Webhook, response.Type); - } - - [Theory, BitAutoData] - public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel)); } [Theory, BitAutoData] public async Task DeleteAsync_AllParamsProvided_Succeeds( 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); - await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id); + await sutProvider.Sut.DeleteAsync(organizationId, integrationId); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegration); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId); } [Theory, BitAutoData] + [Obsolete("Obsolete")] public async Task PostDeleteAsync_AllParamsProvided_Succeeds( 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); - - await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegration); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - organizationIntegration.OrganizationId = Guid.NewGuid(); - 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)); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) + Guid integrationId) { 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)); + await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId); } [Theory, BitAutoData] public async Task DeleteAsync_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.DeleteAsync(organizationId, Guid.Empty)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.DeleteAsync(organizationId, integrationId)); } [Theory, BitAutoData] public async Task UpdateAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration) + Guid integrationId, + OrganizationIntegration integration) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; + integration.OrganizationId = organizationId; + integration.Id = integrationId; + integration.Type = IntegrationType.Webhook; + sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); + sutProvider.GetDependency() + .UpdateAsync(organizationId, integrationId, Arg.Any()) + .Returns(integration); - var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel); + var response = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(organizationIntegration); + await sutProvider.GetDependency().Received(1) + .UpdateAsync(organizationId, integrationId, Arg.Is(i => + i.OrganizationId == organizationId && + i.Type == IntegrationType.Webhook)); Assert.IsType(response); Assert.Equal(IntegrationType.Webhook, response.Type); } - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - organizationIntegration.OrganizationId = Guid.NewGuid(); - 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, _webhookRequestModel)); - } - - [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, _webhookRequestModel)); - } - [Theory, BitAutoData] public async Task UpdateAsync_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.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel)); } } diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..f69a61a322 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -0,0 +1,161 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using StackExchange.Redis; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations; + +public class EventIntegrationServiceCollectionExtensionsTests +{ + private readonly IServiceCollection _services; + private readonly GlobalSettings _globalSettings; + + public EventIntegrationServiceCollectionExtensionsTests() + { + _services = new ServiceCollection(); + _globalSettings = CreateGlobalSettings([]); + + // Add required infrastructure services + _services.TryAddSingleton(_globalSettings); + _services.TryAddSingleton(_globalSettings); + _services.AddLogging(); + + // Mock Redis connection for cache + _services.AddSingleton(Substitute.For()); + + // Mock required repository dependencies for commands + _services.TryAddScoped(_ => Substitute.For()); + _services.TryAddScoped(_ => Substitute.For()); + } + + [Fact] + public void AddEventIntegrationsCommandsQueries_RegistersAllServices() + { + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + + using var provider = _services.BuildServiceProvider(); + + var cache = provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName); + Assert.NotNull(cache); + + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void AddEventIntegrationsCommandsQueries_CommandsQueries_AreRegisteredAsScoped() + { + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + + var createIntegrationDescriptor = _services.First(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)); + + Assert.Equal(ServiceLifetime.Scoped, createIntegrationDescriptor.Lifetime); + } + + [Fact] + public void AddEventIntegrationsCommandsQueries_CommandsQueries_DifferentInstancesPerScope() + { + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + + var provider = _services.BuildServiceProvider(); + + ICreateOrganizationIntegrationCommand? instance1, instance2, instance3; + using (var scope1 = provider.CreateScope()) + { + instance1 = scope1.ServiceProvider.GetService(); + } + using (var scope2 = provider.CreateScope()) + { + instance2 = scope2.ServiceProvider.GetService(); + } + using (var scope3 = provider.CreateScope()) + { + instance3 = scope3.ServiceProvider.GetService(); + } + + Assert.NotNull(instance1); + Assert.NotNull(instance2); + Assert.NotNull(instance3); + Assert.NotSame(instance1, instance2); + Assert.NotSame(instance2, instance3); + Assert.NotSame(instance1, instance3); + } + + [Fact] + public void AddEventIntegrationsCommandsQueries_CommandsQueries__SameInstanceWithinScope() + { + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + var provider = _services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + var instance1 = scope.ServiceProvider.GetService(); + var instance2 = scope.ServiceProvider.GetService(); + + Assert.NotNull(instance1); + Assert.NotNull(instance2); + Assert.Same(instance1, instance2); + } + + [Fact] + public void AddEventIntegrationsCommandsQueries_MultipleCalls_IsIdempotent() + { + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + _services.AddEventIntegrationsCommandsQueries(_globalSettings); + + var createConfigCmdDescriptors = _services.Where(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList(); + Assert.Single(createConfigCmdDescriptors); + + var updateIntegrationCmdDescriptors = _services.Where(s => + s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand)).ToList(); + Assert.Single(updateIntegrationCmdDescriptors); + } + + [Fact] + public void AddOrganizationIntegrationCommandsQueries_RegistersAllIntegrationServices() + { + _services.AddOrganizationIntegrationCommandsQueries(); + + Assert.Contains(_services, s => s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IDeleteOrganizationIntegrationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IGetOrganizationIntegrationsQuery)); + } + + [Fact] + public void AddOrganizationIntegrationCommandsQueries_MultipleCalls_IsIdempotent() + { + _services.AddOrganizationIntegrationCommandsQueries(); + _services.AddOrganizationIntegrationCommandsQueries(); + _services.AddOrganizationIntegrationCommandsQueries(); + + var createCmdDescriptors = _services.Where(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList(); + Assert.Single(createCmdDescriptors); + } + + private static GlobalSettings CreateGlobalSettings(Dictionary data) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(data) + .Build(); + + var settings = new GlobalSettings(); + config.GetSection("GlobalSettings").Bind(settings); + return settings; + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommandTests.cs new file mode 100644 index 0000000000..62af1eb3ed --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommandTests.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +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.OrganizationIntegrations; + +[SutProviderCustomize] +public class CreateOrganizationIntegrationCommandTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_Success_CreatesIntegrationAndInvalidatesCache( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Webhook; + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(integration) + .Returns(integration); + + var result = await sutProvider.Sut.CreateAsync(integration); + + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationAsync(integration.OrganizationId); + await sutProvider.GetDependency().Received(1) + .CreateAsync(integration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + integration.OrganizationId, + integration.Type)); + Assert.Equal(integration, result); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DuplicateType_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration existingIntegration) + { + integration.Type = IntegrationType.Webhook; + existingIntegration.Type = IntegrationType.Webhook; + existingIntegration.OrganizationId = integration.OrganizationId; + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([existingIntegration]); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(integration)); + + Assert.Contains("An integration of this type already exists", exception.Message); + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DifferentType_Success( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration existingIntegration) + { + integration.Type = IntegrationType.Webhook; + existingIntegration.Type = IntegrationType.Slack; + existingIntegration.OrganizationId = integration.OrganizationId; + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([existingIntegration]); + sutProvider.GetDependency() + .CreateAsync(integration) + .Returns(integration); + + var result = await sutProvider.Sut.CreateAsync(integration); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(integration); + Assert.Equal(integration, result); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommandTests.cs new file mode 100644 index 0000000000..25a00bded1 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommandTests.cs @@ -0,0 +1,86 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +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.OrganizationIntegrations; + +[SutProviderCustomize] +public class DeleteOrganizationIntegrationCommandTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_Success_DeletesIntegrationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await sutProvider.Sut.DeleteAsync(organizationId, integrationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(integration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_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.DeleteAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQueryTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQueryTests.cs new file mode 100644 index 0000000000..dfa8e4b306 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQueryTests.cs @@ -0,0 +1,44 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +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.OrganizationIntegrations; + +[SutProviderCustomize] +public class GetOrganizationIntegrationsQueryTests +{ + [Theory, BitAutoData] + public async Task GetManyByOrganizationAsync_CallsRepository( + SutProvider sutProvider, + Guid organizationId, + List integrations) + { + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns(integrations); + + var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId); + + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationAsync(organizationId); + Assert.Equal(integrations.Count, result.Count); + } + + [Theory, BitAutoData] + public async Task GetManyByOrganizationAsync_NoIntegrations_ReturnsEmptyList( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + + var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId); + + Assert.Empty(result); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommandTests.cs new file mode 100644 index 0000000000..fdedec2e51 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommandTests.cs @@ -0,0 +1,121 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +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.OrganizationIntegrations; + +[SutProviderCustomize] +public class UpdateOrganizationIntegrationCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_Success_UpdatesIntegrationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration existingIntegration, + OrganizationIntegration updatedIntegration) + { + existingIntegration.Id = integrationId; + existingIntegration.OrganizationId = organizationId; + existingIntegration.Type = IntegrationType.Webhook; + updatedIntegration.Id = integrationId; + updatedIntegration.OrganizationId = organizationId; + updatedIntegration.Type = IntegrationType.Webhook; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(existingIntegration); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedIntegration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + existingIntegration.Type)); + Assert.Equal(updatedIntegration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration updatedIntegration) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration existingIntegration, + OrganizationIntegration updatedIntegration) + { + existingIntegration.Id = integrationId; + existingIntegration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(existingIntegration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationIsDifferentType_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration existingIntegration, + OrganizationIntegration updatedIntegration) + { + existingIntegration.Id = integrationId; + existingIntegration.OrganizationId = organizationId; + existingIntegration.Type = IntegrationType.Webhook; + updatedIntegration.Id = integrationId; + updatedIntegration.OrganizationId = organizationId; + updatedIntegration.Type = IntegrationType.Hec; // Different Type + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(existingIntegration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +}