1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

Extend Unit Test Coverage of Event Integrations (#6517)

* Extend Unit Test Coverage of Event Integrations

* Expanded SlackService error handling and tests

* Cleaned up a few issues noted by Claude
This commit is contained in:
Brant DeBow
2025-11-10 14:55:36 -05:00
committed by GitHub
parent 746b413cff
commit 212f10d22b
17 changed files with 635 additions and 83 deletions

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;

View File

@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
public SlackTeam Team { get; set; } = new();
}
public class SlackSendMessageResponse : SlackApiResponse
{
[JsonPropertyName("channel")]
public string Channel { get; set; } = string.Empty;
}
public class SlackTeam
{
public string Id { get; set; } = string.Empty;

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.Services;
using Bit.Core.Models.Slack;
namespace Bit.Core.Services;
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
/// and sending messages.</summary>
@@ -54,6 +56,6 @@ public interface ISlackService
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="message">The message text to send.</param>
/// <param name="channelId">The channel ID to send the message to.</param>
/// <returns>A task that completes when the message has been sent.</returns>
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
/// <returns>The response from Slack after sending the message.</returns>
Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@@ -6,14 +6,43 @@ public class SlackIntegrationHandler(
ISlackService slackService)
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
{
private static readonly HashSet<string> _retryableErrors = new(StringComparer.Ordinal)
{
"internal_error",
"message_limit_exceeded",
"rate_limited",
"ratelimited",
"service_unavailable"
};
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{
await slackService.SendSlackMessageByChannelIdAsync(
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.Token,
message.RenderedTemplate,
message.Configuration.ChannelId
);
return new IntegrationHandlerResult(success: true, message: message);
if (slackResponse is null)
{
return new IntegrationHandlerResult(success: false, message: message)
{
FailureReason = "Slack response was null"
};
}
if (slackResponse.Ok)
{
return new IntegrationHandlerResult(success: true, message: message);
}
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
if (_retryableErrors.Contains(slackResponse.Error))
{
result.Retryable = true;
}
return result;
}
}

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
using Bit.Core.Models.Slack;
using Bit.Core.Settings;
@@ -71,7 +72,7 @@ public class SlackService(
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
{
var userId = await GetUserIdByEmailAsync(token, email);
return await OpenDmChannel(token, userId);
return await OpenDmChannelAsync(token, userId);
}
public string GetRedirectUrl(string callbackUrl, string state)
@@ -97,21 +98,21 @@ public class SlackService(
}
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
new FormUrlEncodedContent([
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
}));
]));
SlackOAuthResponse? result;
try
{
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
}
catch
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
result = null;
}
@@ -129,14 +130,25 @@ public class SlackService(
return result.AccessToken;
}
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{
var payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
await _httpClient.SendAsync(request);
var response = await _httpClient.SendAsync(request);
try
{
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
return null;
}
}
private async Task<string> GetUserIdByEmailAsync(string token, string email)
@@ -144,7 +156,16 @@ public class SlackService(
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
SlackUserResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
result = null;
}
if (result is null)
{
@@ -160,7 +181,7 @@ public class SlackService(
return result.User.Id;
}
private async Task<string> OpenDmChannel(string token, string userId)
private async Task<string> OpenDmChannelAsync(string token, string userId)
{
if (string.IsNullOrEmpty(userId))
return string.Empty;
@@ -170,7 +191,16 @@ public class SlackService(
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
SlackDmResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
result = null;
}
if (result is null)
{

View File

@@ -1,4 +1,5 @@
using Bit.Core.Services;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
@@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService
return string.Empty;
}
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{
return Task.FromResult(0);
return Task.FromResult<SlackSendMessageResponse?>(null);
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)

View File

@@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
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,

View File

@@ -51,6 +51,36 @@ public class OrganizationIntegrationsConfigurationControllerTests
.DeleteAsync(organizationIntegrationConfiguration);
}
[Theory, BitAutoData]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.GetByIdAsync(organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.DeleteAsync(organizationIntegrationConfiguration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
@@ -199,27 +229,6 @@ public class OrganizationIntegrationsConfigurationControllerTests
.GetManyByIntegrationAsync(organizationIntegration.Id);
}
// [Theory, BitAutoData]
// public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
// SutProvider<OrganizationIntegrationConfigurationController> 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);
// sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
// .GetByIdAsync(Arg.Any<Guid>())
// .ReturnsNull();
//
// await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty));
// }
//
[Theory, BitAutoData]
public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
@@ -293,15 +302,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -331,15 +341,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -369,15 +380,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -575,7 +587,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -583,11 +595,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
@@ -619,7 +632,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -627,11 +640,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
[Theory, BitAutoData]
@@ -662,7 +676,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -670,11 +684,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
[Theory, BitAutoData]

View File

@@ -71,6 +71,26 @@ public class SlackIntegrationControllerTests
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Slack;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
@@ -153,6 +173,8 @@ public class SlackIntegrationControllerTests
OrganizationIntegration wrongOrgIntegration)
{
wrongOrgIntegration.Id = integration.Id;
wrongOrgIntegration.Type = IntegrationType.Slack;
wrongOrgIntegration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
@@ -304,6 +326,22 @@ public class SlackIntegrationControllerTests
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_CallbackUrlReturnsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(
SutProvider<SlackIntegrationController> sutProvider,

View File

@@ -60,6 +60,26 @@ public class TeamsIntegrationControllerTests
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
@@ -315,6 +335,30 @@ public class TeamsIntegrationControllerTests
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
integration.Type = IntegrationType.Teams;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,

View File

@@ -1,14 +1,47 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModelTests
{
[Fact]
public void ToOrganizationIntegration_CreatesNewOrganizationIntegration()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Hec,
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
};
var organizationId = Guid.NewGuid();
var organizationIntegration = model.ToOrganizationIntegration(organizationId);
Assert.Equal(organizationIntegration.Type, model.Type);
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
Assert.Equal(organizationIntegration.OrganizationId, organizationId);
}
[Theory, BitAutoData]
public void ToOrganizationIntegration_UpdatesExistingOrganizationIntegration(OrganizationIntegration integration)
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Hec,
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
};
var organizationIntegration = model.ToOrganizationIntegration(integration);
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
}
[Fact]
public void Validate_CloudBillingSync_ReturnsNotYetSupportedError()
{

View File

@@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests
Assert.Equal(expected, sut.EventMessage);
}
[Theory, BitAutoData]
public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage)
{
var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc);
eventMessage.Date = testDate;
var sut = new IntegrationTemplateContext(eventMessage);
var result = sut.DateIso8601;
Assert.Equal("2025-10-27T13:30:00.0000000Z", result);
// Verify it's valid ISO 8601
Assert.True(DateTime.TryParse(result, out _));
}
[Theory, BitAutoData]
public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user)
{

View File

@@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
}
[Fact]
public async Task CreateManyAsync_EmptyList_DoesNothing()
{
await Subject.CreateManyAsync([]);
await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
public async Task DisposeAsync_DisposesEventIntegrationPublisher()
{
await Subject.DisposeAsync();
await _eventIntegrationPublisher.Received(1).DisposeAsync();
}
private static bool AssertJsonStringsMatch(EventMessage expected, string body)
{
var actual = JsonSerializer.Deserialize<EventMessage>(body);

View File

@@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
eventMessage.OrganizationId = null;
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
{

View File

@@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage)
{
var userId = Guid.NewGuid();
eventMessage.UserId = userId;
var group = new IntegrationFilterGroup
{
AndOperator = true,
Rules =
[
new()
{
Property = "UserId",
Operation = IntegrationFilterOperation.Equals,
Value = userId.ToString()
}
]
};
var result = _service.EvaluateFilterGroup(group, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(group);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage)
{
@@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage)
{
var id = Guid.NewGuid();
var collectionId = Guid.NewGuid();
eventMessage.UserId = id;
eventMessage.CollectionId = collectionId;
var nestedGroup = new IntegrationFilterGroup
{
AndOperator = false,
Rules =
[
new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id },
new()
{
Property = "CollectionId",
Operation = IntegrationFilterOperation.In,
Value = new Guid?[] { Guid.NewGuid() }
}
]
};
var topGroup = new IntegrationFilterGroup
{
AndOperator = false,
Groups = [nestedGroup]
};
var result = _service.EvaluateFilterGroup(topGroup, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(topGroup);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage)
{

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests
var sutProvider = GetSutProvider();
message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token);
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
@@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("service_unavailable")]
[InlineData("ratelimited")]
[InlineData("rate_limited")]
[InlineData("internal_error")]
[InlineData("message_limit_exceeded")]
public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("access_denied")]
[InlineData("channel_not_found")]
[InlineData("token_expired")]
[InlineData("token_revoked")]
public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Fact]
public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure()
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((SlackSendMessageResponse?)null);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal("Slack response was null", result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
}

View File

@@ -146,6 +146,27 @@ public class SlackServiceTests
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult()
{
var emptyResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = Array.Empty<string>(),
response_metadata = new { next_cursor = "" }
});
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(emptyResponse));
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general");
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_ReturnsCorrectChannelId()
{
@@ -235,6 +256,32 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userId = "U12345";
var userResponse = new
{
ok = true,
user = new { id = userId }
};
_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("NOT JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString()
{
@@ -244,7 +291,7 @@ public class SlackServiceTests
var userResponse = new
{
ok = false,
error = "An error occured"
error = "An error occurred"
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
@@ -256,6 +303,21 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
@@ -341,18 +403,29 @@ public class SlackServiceTests
}
[Fact]
public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = true,
channel = channelId,
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(string.Empty));
.WithContent(new StringContent(jsonResponse));
await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.True(result.Ok);
// Request was sent correctly
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
@@ -365,4 +438,62 @@ public class SlackServiceTests
Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty);
Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty);
}
[Fact]
public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = false,
channel = channelId,
error = "error"
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.False(result.Ok);
Assert.NotNull(result.Error);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
}