diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackConfiguration.cs index e72f2fbbe4..f54f7496c6 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackConfiguration.cs @@ -2,7 +2,16 @@ public class SlackConfiguration { + public SlackConfiguration() + { + } + + public SlackConfiguration(string channelId, string token) + { + ChannelId = channelId; + Token = token; + } + public string Token { get; set; } = string.Empty; - public List Channels { get; set; } = new(); - public List UserEmails { get; set; } = new(); + public string ChannelId { get; set; } = string.Empty; } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 264153bffb..36c16c948d 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -7,7 +7,8 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationConfigurationRepository { - Task?> GetConfigurationAsync(Guid organizationId, IntegrationType integrationType, EventType eventType); + Task>> GetConfigurationsAsync(IntegrationType integrationType, + Guid organizationId, EventType eventType); Task>> GetAllConfigurationsAsync(Guid organizationId); Task AddConfigurationAsync(Guid organizationId, IntegrationType integrationType, EventType eventType, IntegrationConfiguration configuration); Task UpdateConfigurationAsync(IntegrationConfiguration configuration); diff --git a/src/Core/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index 03e3ae2bf4..88542c6c5c 100644 --- a/src/Core/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -4,53 +4,39 @@ using Bit.Core.Settings; namespace Bit.Core.Repositories; -#nullable enable - public class OrganizationIntegrationConfigurationRepository(GlobalSettings globalSettings) : IOrganizationIntegrationConfigurationRepository { - private readonly string _slackToken = globalSettings.EventLogging.SlackToken; - private readonly string _slackUserEmail = globalSettings.EventLogging.SlackUserEmail; - private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl; - - public async Task?> GetConfigurationAsync( + public async Task>> GetConfigurationsAsync(IntegrationType integrationType, Guid organizationId, - IntegrationType integrationType, EventType eventType) { + var configurations = new List>(); switch (integrationType) { case IntegrationType.Slack: - if (string.IsNullOrWhiteSpace(_slackToken) || string.IsNullOrWhiteSpace(_slackUserEmail)) + foreach (var configuration in globalSettings.EventLogging.SlackConfigurations) { - return null; - } - return new IntegrationConfiguration() - { - Configuration = new SlackConfiguration + configurations.Add(new IntegrationConfiguration { - Token = _slackToken, - Channels = new List { }, - UserEmails = new List { _slackUserEmail } - }, - Template = "This is a test of the new Slack integration" - } as IntegrationConfiguration; + Configuration = configuration, + Template = "This is a test of the new Slack integration, #UserId#, #Type#, #Date#" + } as IntegrationConfiguration); + } + break; case IntegrationType.Webhook: - if (string.IsNullOrWhiteSpace(_webhookUrl)) + foreach (var configuration in globalSettings.EventLogging.WebhookConfigurations) { - return null; - } - return new IntegrationConfiguration() - { - Configuration = new WebhookConfiguration() + configurations.Add(new IntegrationConfiguration { - Url = _webhookUrl, - }, - Template = "{ \"Date\": \"#Date#\", \"Type\": \"#Type#\", \"UserId\": \"#UserId#\" }" - } as IntegrationConfiguration; - default: - return null; + Configuration = configuration, + Template = "{ \"Date\": \"#Date#\", \"Type\": \"#Type#\", \"UserId\": \"#UserId#\" }" + } as IntegrationConfiguration); + } + break; } + + return configurations; } public async Task>> GetAllConfigurationsAsync(Guid organizationId) => throw new NotImplementedException(); diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs new file mode 100644 index 0000000000..7dd9f42fb7 --- /dev/null +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Services; + +public interface ISlackService +{ + Task GetChannelIdAsync(string token, string channelName); + Task> GetChannelIdsAsync(string token, List channelNames); + Task GetDmChannelByEmailAsync(string token, string email); + Task SendSlackMessageByChannelId(string token, string message, string channelId); +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs index e8446d0894..759148fe60 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -7,23 +7,23 @@ namespace Bit.Core.Services; public class SlackEventHandler( IOrganizationIntegrationConfigurationRepository configurationRepository, - SlackMessageSender slackMessageSender + ISlackService slackService ) : IEventMessageHandler { public async Task HandleEventAsync(EventMessage eventMessage) { - Guid organizationId = eventMessage.OrganizationId ?? Guid.NewGuid(); - - var configuration = await configurationRepository.GetConfigurationAsync( - organizationId, + var organizationId = eventMessage.OrganizationId ?? Guid.NewGuid(); + var configurations = await configurationRepository.GetConfigurationsAsync( IntegrationType.Slack, - eventMessage.Type); - if (configuration is not null) + organizationId, eventMessage.Type + ); + + foreach (var configuration in configurations) { - await slackMessageSender.SendDirectMessageByEmailAsync( + await slackService.SendSlackMessageByChannelId( configuration.Configuration.Token, - configuration.Template, - configuration.Configuration.UserEmails.First() + TemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), + configuration.Configuration.ChannelId ); } } diff --git a/src/Core/AdminConsole/Services/Implementations/SlackMessageSender.cs b/src/Core/AdminConsole/Services/Implementations/SlackMessageSender.cs deleted file mode 100644 index fa6c9da9b0..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/SlackMessageSender.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class SlackMessageSender( - IHttpClientFactory httpClientFactory, - ILogger logger) -{ - private HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - - public const string HttpClientName = "SlackMessageSenderHttpClient"; - - public async Task SendDirectMessageByEmailAsync(string token, string message, string email) - { - var userId = await UserIdByEmail(token, email); - - if (userId is not null) - { - await SendSlackDirectMessageByUserId(token, message, userId); - } - } - - public async Task UserIdByEmail(string token, string email) - { - var request = new HttpRequestMessage(HttpMethod.Get, $"https://slack.com/api/users.lookupByEmail?email={email}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request); - var content = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var root = content.RootElement; - - if (root.GetProperty("ok").GetBoolean()) - { - return root.GetProperty("user").GetProperty("id").GetString(); - } - else - { - logger.LogError("Error retrieving slack userId: " + root.GetProperty("error").GetString()); - return null; - } - } - - public async Task SendSlackDirectMessageByUserId(string token, string message, string userId) - { - var channelId = await OpenDmChannel(token, userId); - - var payload = JsonContent.Create(new { channel = channelId, text = message }); - var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - request.Content = payload; - - await _httpClient.SendAsync(request); - } - - public async Task OpenDmChannel(string token, string userId) - { - var payload = JsonContent.Create(new { users = userId }); - var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/conversations.open"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - request.Content = payload; - var response = await _httpClient.SendAsync(request); - var content = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var root = content.RootElement; - - if (root.GetProperty("ok").GetBoolean()) - { - return content.RootElement.GetProperty("channel").GetProperty("id").GetString(); - } - else - { - logger.LogError("Error opening DM channel: " + root.GetProperty("error").GetString()); - return null; - } - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/SlackService.cs new file mode 100644 index 0000000000..2580d9fc1a --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/SlackService.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Web; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class SlackService( + IHttpClientFactory httpClientFactory, + ILogger logger) : ISlackService +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public const string HttpClientName = "SlackServiceHttpClient"; + + public async Task GetChannelIdAsync(string token, string channelName) + { + return (await GetChannelIdsAsync(token, new List { channelName }))?.FirstOrDefault(); + } + + public async Task> GetChannelIdsAsync(string token, List channelNames) + { + var matchingChannelIds = new List(); + var baseUrl = "https://slack.com/api/conversations.list"; + var nextCursor = string.Empty; + + do + { + var uriBuilder = new UriBuilder(baseUrl); + var queryParameters = HttpUtility.ParseQueryString(uriBuilder.Query); + + queryParameters["types"] = "public_channel,private_channel"; + queryParameters["limit"] = "1000"; + if (!string.IsNullOrEmpty(nextCursor)) + { + queryParameters["cursor"] = nextCursor; + } + + uriBuilder.Query = queryParameters.ToString(); + + var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request); + var jsonResponse = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = jsonResponse.RootElement; + if (root.GetProperty("ok").GetBoolean()) + { + var channelList = root.GetProperty("channels"); + + foreach (var channel in channelList.EnumerateArray()) + { + if (channelNames.Contains(channel.GetProperty("name").GetString() ?? string.Empty)) + { + matchingChannelIds.Add(channel.GetProperty("id").GetString() ?? string.Empty); + } + } + + if (root.TryGetProperty("response_metadata", out var metadata)) + { + nextCursor = metadata.GetProperty("next_cursor").GetString() ?? string.Empty; + } + else + { + nextCursor = string.Empty; + } + } + else + { + logger.LogError("Error retrieving slack userId: " + root.GetProperty("error").GetString()); + break; + } + } while (!string.IsNullOrEmpty(nextCursor)); + + return matchingChannelIds; + } + + public async Task GetDmChannelByEmailAsync(string token, string email) + { + var userId = await GetUserIdByEmailAsync(token, email); + return await OpenDmChannel(token, userId); + } + + public async Task SendSlackMessageByChannelId(string token, string message, string channelId) + { + var payload = JsonContent.Create(new { channel = channelId, text = message }); + var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = payload; + + await _httpClient.SendAsync(request); + } + + private async Task SendDirectMessageByEmailAsync(string token, string message, string email) + { + var channelId = await GetDmChannelByEmailAsync(token, email); + if (!string.IsNullOrEmpty(channelId)) + { + await SendSlackMessageByChannelId(token, message, channelId); + } + } + + private async Task GetUserIdByEmailAsync(string token, string email) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"https://slack.com/api/users.lookupByEmail?email={email}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await _httpClient.SendAsync(request); + var content = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = content.RootElement; + + if (root.GetProperty("ok").GetBoolean()) + { + return root.GetProperty("user").GetProperty("id").GetString() ?? string.Empty; + } + else + { + logger.LogError("Error retrieving slack userId: " + root.GetProperty("error").GetString()); + return string.Empty; + } + } + + private async Task OpenDmChannel(string token, string userId) + { + var payload = JsonContent.Create(new { users = userId }); + var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/conversations.open"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = payload; + var response = await _httpClient.SendAsync(request); + var content = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = content.RootElement; + + if (root.GetProperty("ok").GetBoolean()) + { + return content.RootElement.GetProperty("channel").GetProperty("id").GetString() ?? string.Empty; + } + else + { + logger.LogError("Error opening DM channel: " + root.GetProperty("error").GetString()); + return string.Empty; + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs index 95e668e5be..8edc11ba4f 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs @@ -21,11 +21,13 @@ public class WebhookEventHandler( { Guid organizationId = eventMessage.OrganizationId ?? Guid.NewGuid(); - var configuration = await configurationRepository.GetConfigurationAsync( - organizationId, + var configurations = await configurationRepository.GetConfigurationsAsync( IntegrationType.Webhook, - eventMessage.Type); - if (configuration is not null) + organizationId, + eventMessage.Type + ); + + foreach (var configuration in configurations) { var content = new StringContent( TemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index cf20eacfe4..d4f385c47a 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Settings; +using Bit.Core.Models.Data.Integrations; using Bit.Core.Settings.LoggingSettings; namespace Bit.Core.Settings; @@ -283,8 +284,10 @@ public class GlobalSettings : IGlobalSettings public class EventLoggingSettings { public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); + public virtual List SlackConfigurations { get; set; } = new List(); + public virtual List WebhookConfigurations { get; set; } = new List(); + public virtual string SlackChannel { get; set; } public virtual string SlackToken { get; set; } - public virtual string SlackUserEmail { get; set; } public virtual string WebhookUrl { get; set; } public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 000aff4d77..3a06f43487 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -120,8 +120,8 @@ public class Startup services.AddSingleton(); - services.AddHttpClient(SlackMessageSender.HttpClientName); - services.AddSingleton(); + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => diff --git a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs new file mode 100644 index 0000000000..040e269359 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs @@ -0,0 +1,175 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Integrations; +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(); + private readonly ISlackService _slackService = Substitute.For(); + 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 GetSutProvider( + List> integrationConfigurations) + { + _repository.GetConfigurationsAsync( + IntegrationType.Slack, + Arg.Any(), + Arg.Any()) + .Returns(integrationConfigurations); + + return new SutProvider() + .SetDependency(_repository) + .SetDependency(_slackService) + .Create(); + } + + private List> NoConfigurations() + { + return []; + } + + private List> OneConfiguration() + { + return + [ + new IntegrationConfiguration + { + Configuration = new SlackConfiguration(channelId: _channelId, token: _token), + Template = "Date: #Date#, Type: #Type#, UserId: #UserId#" + } + ]; + } + + private List> TwoConfigurations() + { + return + [ + new IntegrationConfiguration + { + Configuration = new SlackConfiguration(channelId: _channelId, token: _token), + Template = "Date: #Date#, Type: #Type#, UserId: #UserId#" + }, + + new IntegrationConfiguration + { + Configuration = new SlackConfiguration(channelId: _channelId2, token: _token2), + Template = "Date: #Date#, Type: #Type#, UserId: #UserId#" + } + ]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_DoesNothingWhenNoConfigurations(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs(); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_SendsEventViaSlackService(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + 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_SendsMultipleWhenConfigured(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual( + $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + 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 HandleManyEventsAsync_SendsEventsViaSlackService(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + var received = sutProvider.GetDependency().ReceivedCalls(); + using var calls = received.GetEnumerator(); + + Assert.Equal(eventMessages.Count, received.Count()); + + var index = 0; + 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); + + index++; + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_SendsMultipleEventsAndMultipleConfigurations(List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + var received = sutProvider.GetDependency().ReceivedCalls(); + var calls = received.GetEnumerator(); + + Assert.Equal(eventMessages.Count * 2, received.Count()); + + var index = 0; + 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); + + index++; + } + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs new file mode 100644 index 0000000000..25b6261015 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Text.Json; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class SlackServiceTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + private const string _token = "xoxb-test-token"; + + public SlackServiceTests() + { + _handler = new MockedHttpMessageHandler(); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(SlackService.HttpClientName).Returns(_httpClient); + + return new SutProvider() + .SetDependency(clientFactory) + .Create(); + } + + [Fact] + public async Task GetChannelIdsAsync_Returns_Correct_ChannelIds() + { + var response = JsonSerializer.Serialize( + new + { + ok = true, + channels = + new[] { + new { id = "C12345", name = "general" }, + new { id = "C67890", name = "random" } + }, + response_metadata = new { next_cursor = "" } + } + ); + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(response)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Equal(2, result.Count); + Assert.Contains("C12345", result); + Assert.Contains("C67890", result); + } + + [Fact] + public async Task GetChannelIdsAsync_Handles_Pagination_Correctly() + { + var firstPageResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = new[] { new { id = "C12345", name = "general" } }, + response_metadata = new { next_cursor = "next_cursor_value" } + } + ); + var secondPageResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = new[] { new { id = "C67890", name = "random" } }, + response_metadata = new { next_cursor = "" } + } + ); + + _handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(firstPageResponse)); + _handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000&cursor=next_cursor_value") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(secondPageResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Equal(2, result.Count); + Assert.Contains("C12345", result); + Assert.Contains("C67890", result); + } + + [Fact] + public async Task GetChannelIdsAsync_Handles_Api_Error_Gracefully() + { + var errorResponse = JsonSerializer.Serialize( + new { ok = false, error = "rate_limited" } + ); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.TooManyRequests) + .WithContent(new StringContent(errorResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Empty(result); + } + + [Fact] + public async Task GetChannelIdsAsync_Returns_Empty_When_No_Channels_Found() + { + var emptyResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = Array.Empty(), + response_metadata = new { next_cursor = "" } + }); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(emptyResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Empty(result); + } + + [Fact] + public async Task GetChannelIdAsync_Returns_Correct_ChannelId() + { + var sutProvider = GetSutProvider(); + var response = new + { + ok = true, + channels = new[] + { + new { id = "C12345", name = "general" }, + new { id = "C67890", name = "random" } + }, + response_metadata = new { next_cursor = "" } + }; + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(response))); + + var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general"); + + Assert.Equal("C12345", result); + } + + [Fact] + public async Task GetDmChannelByEmailAsync_Returns_Correct_DmChannelId() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + var userId = "U12345"; + var dmChannelId = "D67890"; + + var userResponse = new + { + ok = true, + user = new { id = userId } + }; + + var dmResponse = new + { + ok = true, + channel = new { id = dmChannelId } + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + _handler.When("https://slack.com/api/conversations.open") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(dmResponse))); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(dmChannelId, result); + } + + [Fact] + public async Task SendSlackMessageByChannelId_Sends_Correct_Message() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello, Slack!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(string.Empty)); + + await sutProvider.Sut.SendSlackMessageByChannelId(_token, message, channelId); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.NotNull(request.Headers.Authorization); + Assert.Equal($"Bearer {_token}", request.Headers.Authorization.ToString()); + Assert.NotNull(request.Content); + var returned = (await request.Content.ReadAsStringAsync()); + var json = JsonDocument.Parse(returned); + Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty); + Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty); + } +} diff --git a/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs index 0dc4a9495a..4d23ec8989 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs @@ -20,7 +20,16 @@ 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() { @@ -31,23 +40,18 @@ public class WebhookEventHandlerTests _httpClient = _handler.ToHttpClient(); } - private SutProvider GetSutProvider() + private SutProvider GetSutProvider( + List> configurations) { var clientFactory = Substitute.For(); clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient); var repository = Substitute.For(); - repository.GetConfigurationAsync( - Arg.Any(), + repository.GetConfigurationsAsync( IntegrationType.Webhook, + Arg.Any(), Arg.Any() - ).Returns( - new IntegrationConfiguration - { - Configuration = new WebhookConfiguration { ApiKey = "", Url = _webhookUrl }, - Template = "{ \"Date\": \"#Date#\", \"Type\": \"#Type#\", \"UserId\": \"#UserId#\" }" - } - ); + ).Returns(configurations); return new SutProvider() .SetDependency(repository) @@ -55,10 +59,57 @@ public class WebhookEventHandlerTests .Create(); } + List> NoConfigurations() + { + return new List>(); + } + + List> OneConfiguration() + { + return new List> + { + new IntegrationConfiguration + { + Configuration = new WebhookConfiguration { Url = _webhookUrl }, + Template = _template + } + }; + } + + List> TwoConfigurations() + { + return new List> + { + new IntegrationConfiguration + { + Configuration = new WebhookConfiguration { Url = _webhookUrl }, + Template = _template + }, + new IntegrationConfiguration + { + Configuration = new WebhookConfiguration { Url = _webhookUrl2 }, + Template = _template + } + }; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_DoesNothingWhenNoConfigurations(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + ); + + Assert.Empty(_handler.CapturedRequests); + } + [Theory, BitAutoData] public async Task HandleEventAsync_PostsEventToUrl(EventMessage eventMessage) { - var sutProvider = GetSutProvider(); + var sutProvider = GetSutProvider(OneConfiguration()); await sutProvider.Sut.HandleEventAsync(eventMessage); sutProvider.GetDependency().Received(1).CreateClient( @@ -77,23 +128,77 @@ public class WebhookEventHandlerTests } [Theory, BitAutoData] - public async Task HandleEventManyAsync_PostsEventsToUrl(List eventMessages) + public async Task HandleManyEventsAsync_DoesNothingWhenNoConfigurations(List eventMessages) { - var sutProvider = GetSutProvider(); + var sutProvider = GetSutProvider(NoConfigurations()); await sutProvider.Sut.HandleManyEventsAsync(eventMessages); sutProvider.GetDependency().Received(1).CreateClient( - Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) ); - var request = _handler.CapturedRequests[0]; - Assert.NotNull(request); - var returned = await request.Content.ReadFromJsonAsync(); - var expected = MockEvent.From(eventMessages.First()); + Assert.Empty(_handler.CapturedRequests); + } - Assert.Equal(HttpMethod.Post, request.Method); - Assert.Equal(_webhookUrl, request.RequestUri.ToString()); - AssertHelper.AssertPropertyEqual(expected, returned); + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_PostsEventsToUrl(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + sutProvider.GetDependency().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(); + 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_PostsEventsToMultipleUrls(List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + sutProvider.GetDependency().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(); + 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(); + AssertHelper.AssertPropertyEqual(expected, returned); + } } }