Add CQRS and caching support for OrganizationIntegrations (#6689)

* Add CQRS and caching support for OrganizationIntegrations

* Use primary constructor for Delete command, per Claude suggestion

* Fix namespace

* Add XMLDoc for new commands / queries

* Remove unnecessary extra call to AddExtendedCache in Startup (call in EventIntegrationsServiceCollectionExtensions handles this instead)

* Alter strategy to use one cache / database call to retrieve all configurations for an event (including wildcards)

* Updated README documentation to reflect updated Caching doc and updated CQRS approach
This commit is contained in:
Brant DeBow 2025-12-05 15:28:07 -05:00 committed by GitHub
parent 3ff59021ae
commit 2504fd9de4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 828 additions and 152 deletions

View File

@ -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<List<OrganizationIntegrationResponseModel>> 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")]

View File

@ -226,7 +226,8 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Add Slack / Teams Services for OAuth API requests - if configured
// Add Event Integrations services
services.AddEventIntegrationsCommandsQueries(globalSettings);
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}

View File

@ -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
{
/// <summary>
/// Adds all event integrations commands, queries, and required cache infrastructure.
/// This method is idempotent and can be called multiple times safely.
/// </summary>
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<ICreateOrganizationIntegrationCommand, CreateOrganizationIntegrationCommand>();
services.TryAddScoped<IUpdateOrganizationIntegrationCommand, UpdateOrganizationIntegrationCommand>();
services.TryAddScoped<IDeleteOrganizationIntegrationCommand, DeleteOrganizationIntegrationCommand>();
services.TryAddScoped<IGetOrganizationIntegrationsQuery, GetOrganizationIntegrationsQuery>();
return services;
}
}

View File

@ -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;
/// <summary>
/// Command implementation for creating organization integrations with cache invalidation support.
/// </summary>
public class CreateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: ICreateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> 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;
}
}

View File

@ -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;
/// <summary>
/// Command implementation for deleting organization integrations with cache invalidation support.
/// </summary>
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
));
}
}

View File

@ -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;
/// <summary>
/// Query implementation for retrieving organization integrations.
/// </summary>
public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository)
: IGetOrganizationIntegrationsQuery
{
public async Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId)
{
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
return integrations.ToList();
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for creating an OrganizationIntegration.
/// </summary>
public interface ICreateOrganizationIntegrationCommand
{
/// <summary>
/// Creates a new organization integration.
/// </summary>
/// <param name="integration">The OrganizationIntegration to create.</param>
/// <returns>The created OrganizationIntegration.</returns>
/// <exception cref="Exceptions.BadRequestException">Thrown when an integration
/// of the same type already exists for the organization.</exception>
Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration);
}

View File

@ -0,0 +1,16 @@
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for deleting organization integrations.
/// </summary>
public interface IDeleteOrganizationIntegrationCommand
{
/// <summary>
/// Deletes an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to delete.</param>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
Task DeleteAsync(Guid organizationId, Guid integrationId);
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Query interface for retrieving organization integrations.
/// </summary>
public interface IGetOrganizationIntegrationsQuery
{
/// <summary>
/// Retrieves all organization integrations for a specific organization.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <returns>A list of organization integrations associated with the organization.</returns>
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
}

View File

@ -0,0 +1,20 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for updating organization integrations.
/// </summary>
public interface IUpdateOrganizationIntegrationCommand
{
/// <summary>
/// Updates an existing organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to update.</param>
/// <param name="updatedIntegration">The updated organization integration data.</param>
/// <returns>The updated organization integration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist,
/// does not belong to the specified organization, or the integration type does not match.</exception>
Task<OrganizationIntegration> UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration);
}

View File

@ -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;
/// <summary>
/// Command implementation for updating organization integrations with cache invalidation support.
/// </summary>
public class UpdateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: IUpdateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> 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;
}
}

View File

@ -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.

View File

@ -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<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
await sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
@ -70,7 +69,7 @@ public class OrganizationIntegrationControllerTests
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.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<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>().Received(1)
.CreateAsync(Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(callInfo => callInfo.Arg<OrganizationIntegration>());
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task DeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
Guid integrationId)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
[Obsolete("Obsolete")]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.DeleteAsync(organizationId, integrationId));
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> 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<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>()
.UpdateAsync(organizationId, integrationId, Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel);
var response = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(organizationIntegration);
await sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>().Received(1)
.UpdateAsync(organizationId, integrationId, Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel));
}
}

View File

@ -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<IGlobalSettings>(_globalSettings);
_services.AddLogging();
// Mock Redis connection for cache
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
// Mock required repository dependencies for commands
_services.TryAddScoped(_ => Substitute.For<IOrganizationIntegrationRepository>());
_services.TryAddScoped(_ => Substitute.For<IOrganizationRepository>());
}
[Fact]
public void AddEventIntegrationsCommandsQueries_RegistersAllServices()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName);
Assert.NotNull(cache);
using var scope = provider.CreateScope();
var sp = scope.ServiceProvider;
Assert.NotNull(sp.GetService<ICreateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IUpdateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IDeleteOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IGetOrganizationIntegrationsQuery>());
}
[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<ICreateOrganizationIntegrationCommand>();
}
using (var scope2 = provider.CreateScope())
{
instance2 = scope2.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
using (var scope3 = provider.CreateScope())
{
instance3 = scope3.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
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<ICreateOrganizationIntegrationCommand>();
var instance2 = scope.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
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<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@ -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<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(integration.OrganizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
integration.OrganizationId,
integration.Type));
Assert.Equal(integration, result);
}
[Theory, BitAutoData]
public async Task CreateAsync_DuplicateType_ThrowsBadRequest(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Webhook;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAsync(integration));
Assert.Contains("An integration of this type already exists", exception.Message);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.CreateAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task CreateAsync_DifferentType_Success(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Slack;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
Assert.Equal(integration, result);
}
}

View File

@ -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<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = organizationId;
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
integration.Type));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}

View File

@ -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<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId,
List<OrganizationIntegration> integrations)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
}
[Theory, BitAutoData]
public async Task GetManyByOrganizationAsync_NoIntegrations_ReturnsEmptyList(
SutProvider<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
Assert.Empty(result);
}
}

View File

@ -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<UpdateOrganizationIntegrationCommand> 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<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(updatedIntegration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
existingIntegration.Type));
Assert.Equal(updatedIntegration, result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration updatedIntegration)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationIsDifferentType_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> 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<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}