Browse Source
* [PM-17562] Add Azure Service Bus support for event integration retries * Cleanup AzureServiceBusIntegrationListenerService.cs; add nullable * Removed IntegrationHandlerBase* since it is no longer used (We removed the subclasses previously) * Changed strategy to assume ApplyRetry always gives us a non-null DelayUntilDate; Added test to confirm as wellfix-identity-resource
14 changed files with 301 additions and 850 deletions
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
#nullable enable |
||||
|
||||
using Azure.Messaging.ServiceBus; |
||||
using Bit.Core.Settings; |
||||
using Microsoft.Extensions.Hosting; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace Bit.Core.Services; |
||||
|
||||
public class AzureServiceBusIntegrationListenerService : BackgroundService |
||||
{ |
||||
private readonly int _maxRetries; |
||||
private readonly string _subscriptionName; |
||||
private readonly string _topicName; |
||||
private readonly IIntegrationHandler _handler; |
||||
private readonly ServiceBusClient _client; |
||||
private readonly ServiceBusProcessor _processor; |
||||
private readonly ServiceBusSender _sender; |
||||
private readonly ILogger<AzureServiceBusIntegrationListenerService> _logger; |
||||
|
||||
public AzureServiceBusIntegrationListenerService( |
||||
IIntegrationHandler handler, |
||||
string subscriptionName, |
||||
GlobalSettings globalSettings, |
||||
ILogger<AzureServiceBusIntegrationListenerService> logger) |
||||
{ |
||||
_handler = handler; |
||||
_logger = logger; |
||||
_maxRetries = globalSettings.EventLogging.AzureServiceBus.MaxRetries; |
||||
_topicName = globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName; |
||||
_subscriptionName = subscriptionName; |
||||
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); |
||||
_processor = _client.CreateProcessor(_topicName, _subscriptionName, new ServiceBusProcessorOptions()); |
||||
_sender = _client.CreateSender(_topicName); |
||||
} |
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken) |
||||
{ |
||||
_processor.ProcessMessageAsync += HandleMessageAsync; |
||||
_processor.ProcessErrorAsync += args => |
||||
{ |
||||
_logger.LogError(args.Exception, "Azure Service Bus error"); |
||||
return Task.CompletedTask; |
||||
}; |
||||
|
||||
await _processor.StartProcessingAsync(cancellationToken); |
||||
} |
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken) |
||||
{ |
||||
await _processor.StopProcessingAsync(cancellationToken); |
||||
await _processor.DisposeAsync(); |
||||
await _sender.DisposeAsync(); |
||||
await _client.DisposeAsync(); |
||||
await base.StopAsync(cancellationToken); |
||||
} |
||||
|
||||
private async Task HandleMessageAsync(ProcessMessageEventArgs args) |
||||
{ |
||||
var json = args.Message.Body.ToString(); |
||||
|
||||
try |
||||
{ |
||||
var result = await _handler.HandleAsync(json); |
||||
var message = result.Message; |
||||
|
||||
if (result.Success) |
||||
{ |
||||
await args.CompleteMessageAsync(args.Message); |
||||
return; |
||||
} |
||||
|
||||
message.ApplyRetry(result.DelayUntilDate); |
||||
|
||||
if (result.Retryable && message.RetryCount < _maxRetries) |
||||
{ |
||||
var scheduledTime = (DateTime)message.DelayUntilDate!; |
||||
var retryMsg = new ServiceBusMessage(message.ToJson()) |
||||
{ |
||||
Subject = args.Message.Subject, |
||||
ScheduledEnqueueTime = scheduledTime |
||||
}; |
||||
|
||||
await _sender.SendMessageAsync(retryMsg); |
||||
} |
||||
else |
||||
{ |
||||
await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable"); |
||||
return; |
||||
} |
||||
|
||||
await args.CompleteMessageAsync(args.Message); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Unhandled error processing ASB message"); |
||||
await args.CompleteMessageAsync(args.Message); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
using Azure.Messaging.ServiceBus; |
||||
using Bit.Core.AdminConsole.Models.Data.Integrations; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Settings; |
||||
|
||||
namespace Bit.Core.Services; |
||||
|
||||
public class AzureServiceBusIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable |
||||
{ |
||||
private readonly ServiceBusClient _client; |
||||
private readonly ServiceBusSender _sender; |
||||
|
||||
public AzureServiceBusIntegrationPublisher(GlobalSettings globalSettings) |
||||
{ |
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); |
||||
_sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName); |
||||
} |
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message) |
||||
{ |
||||
var json = message.ToJson(); |
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json) |
||||
{ |
||||
Subject = message.IntegrationType.ToRoutingKey(), |
||||
}; |
||||
|
||||
await _sender.SendMessageAsync(serviceBusMessage); |
||||
} |
||||
|
||||
public async ValueTask DisposeAsync() |
||||
{ |
||||
await _sender.DisposeAsync(); |
||||
await _client.DisposeAsync(); |
||||
} |
||||
} |
||||
@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
|
||||
using System.Text.Json.Nodes; |
||||
using Bit.Core.AdminConsole.Models.Data.Integrations; |
||||
using Bit.Core.AdminConsole.Utilities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Repositories; |
||||
|
||||
namespace Bit.Core.Services; |
||||
|
||||
public abstract class IntegrationEventHandlerBase( |
||||
IUserRepository userRepository, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationIntegrationConfigurationRepository configurationRepository) |
||||
: IEventMessageHandler |
||||
{ |
||||
public async Task HandleEventAsync(EventMessage eventMessage) |
||||
{ |
||||
var organizationId = eventMessage.OrganizationId ?? Guid.Empty; |
||||
var configurations = await configurationRepository.GetConfigurationDetailsAsync( |
||||
organizationId, |
||||
GetIntegrationType(), |
||||
eventMessage.Type); |
||||
|
||||
foreach (var configuration in configurations) |
||||
{ |
||||
var context = await BuildContextAsync(eventMessage, configuration.Template); |
||||
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); |
||||
|
||||
await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); |
||||
} |
||||
} |
||||
|
||||
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages) |
||||
{ |
||||
foreach (var eventMessage in eventMessages) |
||||
{ |
||||
await HandleEventAsync(eventMessage); |
||||
} |
||||
} |
||||
|
||||
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template) |
||||
{ |
||||
var context = new IntegrationTemplateContext(eventMessage); |
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) |
||||
{ |
||||
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); |
||||
} |
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) |
||||
{ |
||||
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); |
||||
} |
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) |
||||
{ |
||||
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); |
||||
} |
||||
|
||||
return context; |
||||
} |
||||
|
||||
protected abstract IntegrationType GetIntegrationType(); |
||||
|
||||
protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); |
||||
} |
||||
@ -1,35 +0,0 @@
@@ -1,35 +0,0 @@
|
||||
using System.Text.Json; |
||||
using System.Text.Json.Nodes; |
||||
using Bit.Core.AdminConsole.Models.Data.Integrations; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Repositories; |
||||
|
||||
#nullable enable |
||||
|
||||
namespace Bit.Core.Services; |
||||
|
||||
public class SlackEventHandler( |
||||
IUserRepository userRepository, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationIntegrationConfigurationRepository configurationRepository, |
||||
ISlackService slackService) |
||||
: IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) |
||||
{ |
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; |
||||
|
||||
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, |
||||
string renderedTemplate) |
||||
{ |
||||
var config = mergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>(); |
||||
if (config is null) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
await slackService.SendSlackMessageByChannelIdAsync( |
||||
config.token, |
||||
renderedTemplate, |
||||
config.channelId |
||||
); |
||||
} |
||||
} |
||||
@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
|
||||
using System.Text; |
||||
using System.Text.Json; |
||||
using System.Text.Json.Nodes; |
||||
using Bit.Core.AdminConsole.Models.Data.Integrations; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Repositories; |
||||
|
||||
#nullable enable |
||||
|
||||
namespace Bit.Core.Services; |
||||
|
||||
public class WebhookEventHandler( |
||||
IHttpClientFactory httpClientFactory, |
||||
IUserRepository userRepository, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationIntegrationConfigurationRepository configurationRepository) |
||||
: IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) |
||||
{ |
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); |
||||
|
||||
public const string HttpClientName = "WebhookEventHandlerHttpClient"; |
||||
|
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; |
||||
|
||||
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, |
||||
string renderedTemplate) |
||||
{ |
||||
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetails>(); |
||||
if (config is null || string.IsNullOrEmpty(config.url)) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); |
||||
var response = await _httpClient.PostAsync(config.url, content); |
||||
response.EnsureSuccessStatusCode(); |
||||
} |
||||
} |
||||
@ -1,219 +0,0 @@
@@ -1,219 +0,0 @@
|
||||
using System.Text.Json; |
||||
using System.Text.Json.Nodes; |
||||
using Bit.Core.AdminConsole.Entities; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Models.Data.Organizations; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Core.Test.Services; |
||||
|
||||
[SutProviderCustomize] |
||||
public class IntegrationEventHandlerBaseEventHandlerTests |
||||
{ |
||||
private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#"; |
||||
private const string _templateWithOrganization = "Org: #OrganizationName#"; |
||||
private const string _templateWithUser = "#UserName#, #UserEmail#"; |
||||
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#"; |
||||
private const string _url = "https://localhost"; |
||||
|
||||
private SutProvider<TestIntegrationEventHandlerBase> GetSutProvider( |
||||
List<OrganizationIntegrationConfigurationDetails> configurations) |
||||
{ |
||||
var configurationRepository = Substitute.For<IOrganizationIntegrationConfigurationRepository>(); |
||||
configurationRepository.GetConfigurationDetailsAsync(Arg.Any<Guid>(), |
||||
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations); |
||||
|
||||
return new SutProvider<TestIntegrationEventHandlerBase>() |
||||
.SetDependency(configurationRepository) |
||||
.Create(); |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations() |
||||
{ |
||||
return []; |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> OneConfiguration(string template) |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = null; |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); |
||||
config.Template = template; |
||||
|
||||
return [config]; |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> TwoConfigurations(string template) |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = null; |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); |
||||
config.Template = template; |
||||
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config2.Configuration = null; |
||||
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); |
||||
config2.Template = template; |
||||
|
||||
return [config, config2]; |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(NoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
Assert.Empty(sutProvider.Sut.CapturedCalls); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls); |
||||
|
||||
var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; |
||||
|
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); |
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>()); |
||||
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>()); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); |
||||
var user = Substitute.For<User>(); |
||||
user.Email = "test@example.com"; |
||||
user.Name = "Test"; |
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user); |
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls); |
||||
|
||||
var expectedTemplate = $"{user.Name}, {user.Email}"; |
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); |
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>()); |
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); |
||||
var organization = Substitute.For<Organization>(); |
||||
organization.Name = "Test"; |
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization); |
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls); |
||||
|
||||
var expectedTemplate = $"Org: {organization.Name}"; |
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); |
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty); |
||||
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>()); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); |
||||
var user = Substitute.For<User>(); |
||||
user.Email = "test@example.com"; |
||||
user.Name = "Test"; |
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user); |
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls); |
||||
|
||||
var expectedTemplate = $"{user.Name}, {user.Email}"; |
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); |
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>()); |
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(NoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
Assert.Empty(sutProvider.Sut.CapturedCalls); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
|
||||
Assert.Equal(eventMessages.Count, sutProvider.Sut.CapturedCalls.Count); |
||||
var index = 0; |
||||
foreach (var call in sutProvider.Sut.CapturedCalls) |
||||
{ |
||||
var expected = eventMessages[index]; |
||||
var expectedTemplate = $"Date: {expected.Date}, Type: {expected.Type}, UserId: {expected.UserId}"; |
||||
|
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate); |
||||
index++; |
||||
} |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes( |
||||
List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
|
||||
Assert.Equal(eventMessages.Count * 2, sutProvider.Sut.CapturedCalls.Count); |
||||
|
||||
var capturedCalls = sutProvider.Sut.CapturedCalls.GetEnumerator(); |
||||
foreach (var eventMessage in eventMessages) |
||||
{ |
||||
var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; |
||||
|
||||
Assert.True(capturedCalls.MoveNext()); |
||||
var call = capturedCalls.Current; |
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate); |
||||
|
||||
Assert.True(capturedCalls.MoveNext()); |
||||
call = capturedCalls.Current; |
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate); |
||||
} |
||||
} |
||||
|
||||
private class TestIntegrationEventHandlerBase : IntegrationEventHandlerBase |
||||
{ |
||||
public TestIntegrationEventHandlerBase(IUserRepository userRepository, |
||||
IOrganizationRepository organizationRepository, |
||||
IOrganizationIntegrationConfigurationRepository configurationRepository) |
||||
: base(userRepository, organizationRepository, configurationRepository) |
||||
{ } |
||||
|
||||
public List<(JsonObject MergedConfiguration, string RenderedTemplate)> CapturedCalls { get; } = new(); |
||||
|
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; |
||||
|
||||
protected override Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate) |
||||
{ |
||||
CapturedCalls.Add((mergedConfiguration, renderedTemplate)); |
||||
return Task.CompletedTask; |
||||
} |
||||
} |
||||
} |
||||
@ -1,181 +0,0 @@
@@ -1,181 +0,0 @@
|
||||
using System.Text.Json; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Models.Data.Organizations; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using Bit.Test.Common.Helpers; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Core.Test.Services; |
||||
|
||||
[SutProviderCustomize] |
||||
public class SlackEventHandlerTests |
||||
{ |
||||
private readonly IOrganizationIntegrationConfigurationRepository _repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>(); |
||||
private readonly ISlackService _slackService = Substitute.For<ISlackService>(); |
||||
private readonly string _channelId = "C12345"; |
||||
private readonly string _channelId2 = "C67890"; |
||||
private readonly string _token = "xoxb-test-token"; |
||||
private readonly string _token2 = "xoxb-another-test-token"; |
||||
|
||||
private SutProvider<SlackEventHandler> GetSutProvider( |
||||
List<OrganizationIntegrationConfigurationDetails> integrationConfigurations) |
||||
{ |
||||
_repository.GetConfigurationDetailsAsync(Arg.Any<Guid>(), |
||||
IntegrationType.Slack, Arg.Any<EventType>()) |
||||
.Returns(integrationConfigurations); |
||||
|
||||
return new SutProvider<SlackEventHandler>() |
||||
.SetDependency(_repository) |
||||
.SetDependency(_slackService) |
||||
.Create(); |
||||
} |
||||
|
||||
private List<OrganizationIntegrationConfigurationDetails> NoConfigurations() |
||||
{ |
||||
return []; |
||||
} |
||||
|
||||
private List<OrganizationIntegrationConfigurationDetails> OneConfiguration() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = JsonSerializer.Serialize(new { token = _token }); |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId }); |
||||
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; |
||||
|
||||
return [config]; |
||||
} |
||||
|
||||
private List<OrganizationIntegrationConfigurationDetails> TwoConfigurations() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = JsonSerializer.Serialize(new { token = _token }); |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId }); |
||||
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; |
||||
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config2.Configuration = JsonSerializer.Serialize(new { token = _token2 }); |
||||
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId2 }); |
||||
config2.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; |
||||
|
||||
return [config, config2]; |
||||
} |
||||
|
||||
private List<OrganizationIntegrationConfigurationDetails> WrongConfiguration() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = JsonSerializer.Serialize(new { }); |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { }); |
||||
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; |
||||
|
||||
return [config]; |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(NoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
sutProvider.GetDependency<ISlackService>().DidNotReceiveWithAnyArgs(); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_OneConfiguration_SendsEventViaSlackService(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token)), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual( |
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) |
||||
); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_TwoConfigurations_SendsMultipleEvents(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(TwoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token)), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual( |
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) |
||||
); |
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token2)), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual( |
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId2)) |
||||
); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_WrongConfiguration_DoesNothing(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(WrongConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
sutProvider.GetDependency<ISlackService>().DidNotReceiveWithAnyArgs(); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_OneConfiguration_SendsEventsViaSlackService(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
|
||||
var received = sutProvider.GetDependency<ISlackService>().ReceivedCalls(); |
||||
using var calls = received.GetEnumerator(); |
||||
|
||||
Assert.Equal(eventMessages.Count, received.Count()); |
||||
|
||||
foreach (var eventMessage in eventMessages) |
||||
{ |
||||
Assert.True(calls.MoveNext()); |
||||
var arguments = calls.Current.GetArguments(); |
||||
Assert.Equal(_token, arguments[0] as string); |
||||
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", |
||||
arguments[1] as string); |
||||
Assert.Equal(_channelId, arguments[2] as string); |
||||
} |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_TwoConfigurations_SendsMultipleEvents(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(TwoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
|
||||
var received = sutProvider.GetDependency<ISlackService>().ReceivedCalls(); |
||||
using var calls = received.GetEnumerator(); |
||||
|
||||
Assert.Equal(eventMessages.Count * 2, received.Count()); |
||||
|
||||
foreach (var eventMessage in eventMessages) |
||||
{ |
||||
Assert.True(calls.MoveNext()); |
||||
var arguments = calls.Current.GetArguments(); |
||||
Assert.Equal(_token, arguments[0] as string); |
||||
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", |
||||
arguments[1] as string); |
||||
Assert.Equal(_channelId, arguments[2] as string); |
||||
|
||||
Assert.True(calls.MoveNext()); |
||||
var arguments2 = calls.Current.GetArguments(); |
||||
Assert.Equal(_token2, arguments2[0] as string); |
||||
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", |
||||
arguments2[1] as string); |
||||
Assert.Equal(_channelId2, arguments2[2] as string); |
||||
} |
||||
} |
||||
} |
||||
@ -1,235 +0,0 @@
@@ -1,235 +0,0 @@
|
||||
using System.Net; |
||||
using System.Net.Http.Json; |
||||
using System.Text.Json; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Models.Data.Organizations; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using Bit.Test.Common.Helpers; |
||||
using Bit.Test.Common.MockedHttpClient; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Core.Test.Services; |
||||
|
||||
[SutProviderCustomize] |
||||
public class WebhookEventHandlerTests |
||||
{ |
||||
private readonly MockedHttpMessageHandler _handler; |
||||
private readonly HttpClient _httpClient; |
||||
|
||||
private const string _template = |
||||
"""
|
||||
{ |
||||
"Date": "#Date#", |
||||
"Type": "#Type#", |
||||
"UserId": "#UserId#" |
||||
} |
||||
""";
|
||||
private const string _webhookUrl = "http://localhost/test/event"; |
||||
private const string _webhookUrl2 = "http://localhost/another/event"; |
||||
|
||||
public WebhookEventHandlerTests() |
||||
{ |
||||
_handler = new MockedHttpMessageHandler(); |
||||
_handler.Fallback |
||||
.WithStatusCode(HttpStatusCode.OK) |
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>")); |
||||
_httpClient = _handler.ToHttpClient(); |
||||
} |
||||
|
||||
private SutProvider<WebhookEventHandler> GetSutProvider( |
||||
List<OrganizationIntegrationConfigurationDetails> configurations) |
||||
{ |
||||
var clientFactory = Substitute.For<IHttpClientFactory>(); |
||||
clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient); |
||||
|
||||
var repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>(); |
||||
repository.GetConfigurationDetailsAsync(Arg.Any<Guid>(), |
||||
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations); |
||||
|
||||
return new SutProvider<WebhookEventHandler>() |
||||
.SetDependency(repository) |
||||
.SetDependency(clientFactory) |
||||
.Create(); |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations() |
||||
{ |
||||
return []; |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> OneConfiguration() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = null; |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl }); |
||||
config.Template = _template; |
||||
|
||||
return [config]; |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> TwoConfigurations() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = null; |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl }); |
||||
config.Template = _template; |
||||
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config2.Configuration = null; |
||||
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl2 }); |
||||
config2.Template = _template; |
||||
|
||||
return [config, config2]; |
||||
} |
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> WrongConfiguration() |
||||
{ |
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>(); |
||||
config.Configuration = null; |
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { error = string.Empty }); |
||||
config.Template = _template; |
||||
|
||||
return [config]; |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(NoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
Assert.Empty(_handler.CapturedRequests); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_OneConfiguration_PostsEventToUrl(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
Assert.Single(_handler.CapturedRequests); |
||||
var request = _handler.CapturedRequests[0]; |
||||
Assert.NotNull(request); |
||||
var returned = await request.Content.ReadFromJsonAsync<MockEvent>(); |
||||
var expected = MockEvent.From(eventMessage); |
||||
|
||||
Assert.Equal(HttpMethod.Post, request.Method); |
||||
Assert.Equal(_webhookUrl, request.RequestUri.ToString()); |
||||
AssertHelper.AssertPropertyEqual(expected, returned); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleEventAsync_WrongConfigurations_DoesNothing(EventMessage eventMessage) |
||||
{ |
||||
var sutProvider = GetSutProvider(WrongConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
Assert.Empty(_handler.CapturedRequests); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_NoConfigurations_DoesNothing(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(NoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
Assert.Empty(_handler.CapturedRequests); |
||||
} |
||||
|
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_OneConfiguration_PostsEventsToUrl(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(OneConfiguration()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
Assert.Equal(eventMessages.Count, _handler.CapturedRequests.Count); |
||||
var index = 0; |
||||
foreach (var request in _handler.CapturedRequests) |
||||
{ |
||||
Assert.NotNull(request); |
||||
var returned = await request.Content.ReadFromJsonAsync<MockEvent>(); |
||||
var expected = MockEvent.From(eventMessages[index]); |
||||
|
||||
Assert.Equal(HttpMethod.Post, request.Method); |
||||
Assert.Equal(_webhookUrl, request.RequestUri.ToString()); |
||||
AssertHelper.AssertPropertyEqual(expected, returned); |
||||
index++; |
||||
} |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleManyEventsAsync_TwoConfigurations_PostsEventsToMultipleUrls(List<EventMessage> eventMessages) |
||||
{ |
||||
var sutProvider = GetSutProvider(TwoConfigurations()); |
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); |
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( |
||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) |
||||
); |
||||
|
||||
using var capturedRequests = _handler.CapturedRequests.GetEnumerator(); |
||||
Assert.Equal(eventMessages.Count * 2, _handler.CapturedRequests.Count); |
||||
|
||||
foreach (var eventMessage in eventMessages) |
||||
{ |
||||
var expected = MockEvent.From(eventMessage); |
||||
|
||||
Assert.True(capturedRequests.MoveNext()); |
||||
var request = capturedRequests.Current; |
||||
Assert.NotNull(request); |
||||
Assert.Equal(HttpMethod.Post, request.Method); |
||||
Assert.Equal(_webhookUrl, request.RequestUri.ToString()); |
||||
var returned = await request.Content.ReadFromJsonAsync<MockEvent>(); |
||||
AssertHelper.AssertPropertyEqual(expected, returned); |
||||
|
||||
Assert.True(capturedRequests.MoveNext()); |
||||
request = capturedRequests.Current; |
||||
Assert.NotNull(request); |
||||
Assert.Equal(HttpMethod.Post, request.Method); |
||||
Assert.Equal(_webhookUrl2, request.RequestUri.ToString()); |
||||
returned = await request.Content.ReadFromJsonAsync<MockEvent>(); |
||||
AssertHelper.AssertPropertyEqual(expected, returned); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public class MockEvent(string date, string type, string userId) |
||||
{ |
||||
public string Date { get; set; } = date; |
||||
public string Type { get; set; } = type; |
||||
public string UserId { get; set; } = userId; |
||||
|
||||
public static MockEvent From(EventMessage eventMessage) |
||||
{ |
||||
return new MockEvent( |
||||
eventMessage.Date.ToString(), |
||||
eventMessage.Type.ToString(), |
||||
eventMessage.UserId.ToString() |
||||
); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue