diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs new file mode 100644 index 0000000000..5df9544c29 --- /dev/null +++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; + +public interface IEventMessageHandler +{ + Task HandleEventAsync(EventMessage eventMessage); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs new file mode 100644 index 0000000000..6e4158122c --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs @@ -0,0 +1,14 @@ +using Bit.Core.Models.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Services; + +public class EventRepositoryHandler( + [FromKeyedServices("persistent")] IEventWriteService eventWriteService) + : IEventMessageHandler +{ + public Task HandleEventAsync(EventMessage eventMessage) + { + return eventWriteService.CreateAsync(eventMessage); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs similarity index 52% rename from src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs rename to src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs index 5a875f9278..8aece0c1da 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs +++ b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs @@ -1,32 +1,25 @@ using System.Net.Http.Json; using Bit.Core.Models.Data; using Bit.Core.Settings; -using Microsoft.Extensions.Logging; namespace Bit.Core.Services; -public class RabbitMqEventHttpPostListener : RabbitMqEventListenerBase +public class HttpPostEventHandler : IEventMessageHandler { private readonly HttpClient _httpClient; private readonly string _httpPostUrl; - private readonly string _queueName; - protected override string QueueName => _queueName; + public const string HttpClientName = "HttpPostEventHandlerHttpClient"; - public const string HttpClientName = "EventHttpPostListenerHttpClient"; - - public RabbitMqEventHttpPostListener( + public HttpPostEventHandler( IHttpClientFactory httpClientFactory, - ILogger logger, GlobalSettings globalSettings) - : base(logger, globalSettings) { _httpClient = httpClientFactory.CreateClient(HttpClientName); _httpPostUrl = globalSettings.EventLogging.RabbitMq.HttpPostUrl; - _queueName = globalSettings.EventLogging.RabbitMq.HttpPostQueueName; } - protected override async Task HandleMessageAsync(EventMessage eventMessage) + public async Task HandleEventAsync(EventMessage eventMessage) { var content = JsonContent.Create(eventMessage); var response = await _httpClient.PostAsync(_httpPostUrl, content); diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs deleted file mode 100644 index 25d85bddeb..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Bit.Core.Models.Data; -using Bit.Core.Settings; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class RabbitMqEventRepositoryListener : RabbitMqEventListenerBase -{ - private readonly IEventWriteService _eventWriteService; - private readonly string _queueName; - - protected override string QueueName => _queueName; - - public RabbitMqEventRepositoryListener( - [FromKeyedServices("persistent")] IEventWriteService eventWriteService, - ILogger logger, - GlobalSettings globalSettings) - : base(logger, globalSettings) - { - _eventWriteService = eventWriteService; - _queueName = globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName; - } - - protected override Task HandleMessageAsync(EventMessage eventMessage) - { - return _eventWriteService.CreateAsync(eventMessage); - } -} diff --git a/src/Core/Services/EventLoggingListenerService.cs b/src/Core/Services/EventLoggingListenerService.cs new file mode 100644 index 0000000000..60b8789a6b --- /dev/null +++ b/src/Core/Services/EventLoggingListenerService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +public abstract class EventLoggingListenerService : BackgroundService +{ + protected readonly IEventMessageHandler _handler; + + protected EventLoggingListenerService(IEventMessageHandler handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs similarity index 78% rename from src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs rename to src/Core/Services/Implementations/RabbitMqEventListenerService.cs index 48a549d261..9360170368 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs +++ b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs @@ -1,26 +1,26 @@ using System.Text.Json; using Bit.Core.Models.Data; using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using RabbitMQ.Client.Events; namespace Bit.Core.Services; -public abstract class RabbitMqEventListenerBase : BackgroundService +public class RabbitMqEventListenerService : EventLoggingListenerService { private IChannel _channel; private IConnection _connection; private readonly string _exchangeName; private readonly ConnectionFactory _factory; - private readonly ILogger _logger; + private readonly ILogger _logger; + private readonly string _queueName; - protected abstract string QueueName { get; } - - protected RabbitMqEventListenerBase( - ILogger logger, - GlobalSettings globalSettings) + public RabbitMqEventListenerService( + IEventMessageHandler handler, + ILogger logger, + GlobalSettings globalSettings, + string queueName) : base(handler) { _factory = new ConnectionFactory { @@ -30,6 +30,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService }; _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName; _logger = logger; + _queueName = queueName; } public override async Task StartAsync(CancellationToken cancellationToken) @@ -38,13 +39,13 @@ public abstract class RabbitMqEventListenerBase : BackgroundService _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); await _channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); - await _channel.QueueDeclareAsync(queue: QueueName, + await _channel.QueueDeclareAsync(queue: _queueName, durable: true, exclusive: false, autoDelete: false, arguments: null, cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: QueueName, + await _channel.QueueBindAsync(queue: _queueName, exchange: _exchangeName, routingKey: string.Empty, cancellationToken: cancellationToken); @@ -59,7 +60,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService try { var eventMessage = JsonSerializer.Deserialize(eventArgs.Body.Span); - await HandleMessageAsync(eventMessage); + await _handler.HandleEventAsync(eventMessage); } catch (Exception ex) { @@ -67,7 +68,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService } }; - await _channel.BasicConsumeAsync(QueueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken); + await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken); while (!stoppingToken.IsCancellationRequested) { @@ -88,6 +89,4 @@ public abstract class RabbitMqEventListenerBase : BackgroundService _connection.Dispose(); base.Dispose(); } - - protected abstract Task HandleMessageAsync(EventMessage eventMessage); } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index afe35ed34b..b89df8abf5 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -27,4 +27,5 @@ public interface IGlobalSettings string DatabaseProvider { get; set; } GlobalSettings.SqlSettings SqlServer { get; set; } string DevelopmentDirectory { get; set; } + GlobalSettings.EventLoggingSettings EventLogging { get; set; } } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 03e99f14e8..b692733a55 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -89,13 +89,26 @@ public class Startup CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) { + services.AddSingleton(); services.AddKeyedSingleton("persistent"); - services.AddHostedService(); + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName)); if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HttpPostUrl)) { - services.AddHttpClient(RabbitMqEventHttpPostListener.HttpClientName); - services.AddHostedService(); + services.AddSingleton(); + services.AddHttpClient(HttpPostEventHandler.HttpClientName); + + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + globalSettings.EventLogging.RabbitMq.HttpPostQueueName)); } } } diff --git a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs index 1b1bd52a03..8a6c1dae97 100644 --- a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs +++ b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs @@ -8,6 +8,8 @@ public class MockedHttpMessageHandler : HttpMessageHandler { private readonly List _matchers = new(); + public List CapturedRequests { get; } = new List(); + /// /// The fallback handler to use when the request does not match any of the provided matchers. /// @@ -16,6 +18,7 @@ public class MockedHttpMessageHandler : HttpMessageHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + CapturedRequests.Add(request); var matcher = _matchers.FirstOrDefault(x => x.Matches(request)); if (matcher == null) { diff --git a/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs new file mode 100644 index 0000000000..2b143f5cb8 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs @@ -0,0 +1,24 @@ +using Bit.Core.Models.Data; +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 EventRepositoryHandlerTests +{ + [Theory, BitAutoData] + public async Task HandleEventAsync_WritesEventToIEventWriteService( + EventMessage eventMessage, + SutProvider sutProvider) + { + await sutProvider.Sut.HandleEventAsync(eventMessage); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(eventMessage)) + ); + } +} diff --git a/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs new file mode 100644 index 0000000000..414b1c54be --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Net.Http.Json; +using Bit.Core.Models.Data; +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; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class HttpPostEventHandlerTests +{ + private readonly MockedHttpMessageHandler _handler; + private HttpClient _httpClient; + + private const string _httpPostUrl = "http://localhost/test/event"; + + public HttpPostEventHandlerTests() + { + _handler = new MockedHttpMessageHandler(); + _handler.Fallback + .WithStatusCode(HttpStatusCode.OK) + .WithContent(new StringContent("testtest")); + _httpClient = _handler.ToHttpClient(); + } + + public SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(HttpPostEventHandler.HttpClientName).Returns(_httpClient); + + var globalSettings = new GlobalSettings(); + globalSettings.EventLogging.RabbitMq.HttpPostUrl = _httpPostUrl; + + return new SutProvider() + .SetDependency(globalSettings) + .SetDependency(clientFactory) + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_PostsEventsToUrl(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(); + var content = JsonContent.Create(eventMessage); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(HttpPostEventHandler.HttpClientName)) + ); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + var returned = await request.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_httpPostUrl, request.RequestUri.ToString()); + AssertHelper.AssertPropertyEqual(eventMessage, returned, new[] { "IdempotencyId" }); + } +}