Skip to content

Commit 212f10d

Browse files
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
1 parent 746b413 commit 212f10d

File tree

17 files changed

+635
-83
lines changed

17 files changed

+635
-83
lines changed

src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@
22
using Bit.Core.Enums;
33
using Bit.Core.Models.Api;
44

5-
#nullable enable
6-
75
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
86

97
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
108
{
119
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
1210
: base(obj)
1311
{
14-
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
15-
1612
Id = organizationIntegrationConfiguration.Id;
1713
Configuration = organizationIntegrationConfiguration.Configuration;
1814
CreationDate = organizationIntegrationConfiguration.CreationDate;

src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
3333
public SlackTeam Team { get; set; } = new();
3434
}
3535

36+
public class SlackSendMessageResponse : SlackApiResponse
37+
{
38+
[JsonPropertyName("channel")]
39+
public string Channel { get; set; } = string.Empty;
40+
}
41+
3642
public class SlackTeam
3743
{
3844
public string Id { get; set; } = string.Empty;

src/Core/AdminConsole/Services/ISlackService.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace Bit.Core.Services;
1+
using Bit.Core.Models.Slack;
2+
3+
namespace Bit.Core.Services;
24

35
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
46
/// and sending messages.</summary>
@@ -54,6 +56,6 @@ public interface ISlackService
5456
/// <param name="token">A valid Slack OAuth access token.</param>
5557
/// <param name="message">The message text to send.</param>
5658
/// <param name="channelId">The channel ID to send the message to.</param>
57-
/// <returns>A task that completes when the message has been sent.</returns>
58-
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
59+
/// <returns>The response from Slack after sending the message.</returns>
60+
Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
5961
}

src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,43 @@ public class SlackIntegrationHandler(
66
ISlackService slackService)
77
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
88
{
9+
private static readonly HashSet<string> _retryableErrors = new(StringComparer.Ordinal)
10+
{
11+
"internal_error",
12+
"message_limit_exceeded",
13+
"rate_limited",
14+
"ratelimited",
15+
"service_unavailable"
16+
};
17+
918
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
1019
{
11-
await slackService.SendSlackMessageByChannelIdAsync(
20+
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
1221
message.Configuration.Token,
1322
message.RenderedTemplate,
1423
message.Configuration.ChannelId
1524
);
1625

17-
return new IntegrationHandlerResult(success: true, message: message);
26+
if (slackResponse is null)
27+
{
28+
return new IntegrationHandlerResult(success: false, message: message)
29+
{
30+
FailureReason = "Slack response was null"
31+
};
32+
}
33+
34+
if (slackResponse.Ok)
35+
{
36+
return new IntegrationHandlerResult(success: true, message: message);
37+
}
38+
39+
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
40+
41+
if (_retryableErrors.Contains(slackResponse.Error))
42+
{
43+
result.Retryable = true;
44+
}
45+
46+
return result;
1847
}
1948
}

src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Net.Http.Headers;
22
using System.Net.Http.Json;
3+
using System.Text.Json;
34
using System.Web;
45
using Bit.Core.Models.Slack;
56
using Bit.Core.Settings;
@@ -71,7 +72,7 @@ public async Task<List<string>> GetChannelIdsAsync(string token, List<string> ch
7172
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
7273
{
7374
var userId = await GetUserIdByEmailAsync(token, email);
74-
return await OpenDmChannel(token, userId);
75+
return await OpenDmChannelAsync(token, userId);
7576
}
7677

7778
public string GetRedirectUrl(string callbackUrl, string state)
@@ -97,21 +98,21 @@ public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
9798
}
9899

99100
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
100-
new FormUrlEncodedContent(new[]
101-
{
101+
new FormUrlEncodedContent([
102102
new KeyValuePair<string, string>("client_id", _clientId),
103103
new KeyValuePair<string, string>("client_secret", _clientSecret),
104104
new KeyValuePair<string, string>("code", code),
105105
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
106-
}));
106+
]));
107107

108108
SlackOAuthResponse? result;
109109
try
110110
{
111111
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
112112
}
113-
catch
113+
catch (JsonException ex)
114114
{
115+
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
115116
result = null;
116117
}
117118

@@ -129,22 +130,42 @@ public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
129130
return result.AccessToken;
130131
}
131132

132-
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
133+
public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
134+
string channelId)
133135
{
134136
var payload = JsonContent.Create(new { channel = channelId, text = message });
135137
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
136138
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
137139
request.Content = payload;
138140

139-
await _httpClient.SendAsync(request);
141+
var response = await _httpClient.SendAsync(request);
142+
143+
try
144+
{
145+
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
146+
}
147+
catch (JsonException ex)
148+
{
149+
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
150+
return null;
151+
}
140152
}
141153

142154
private async Task<string> GetUserIdByEmailAsync(string token, string email)
143155
{
144156
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
145157
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
146158
var response = await _httpClient.SendAsync(request);
147-
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
159+
SlackUserResponse? result;
160+
try
161+
{
162+
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
163+
}
164+
catch (JsonException ex)
165+
{
166+
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
167+
result = null;
168+
}
148169

149170
if (result is null)
150171
{
@@ -160,7 +181,7 @@ private async Task<string> GetUserIdByEmailAsync(string token, string email)
160181
return result.User.Id;
161182
}
162183

163-
private async Task<string> OpenDmChannel(string token, string userId)
184+
private async Task<string> OpenDmChannelAsync(string token, string userId)
164185
{
165186
if (string.IsNullOrEmpty(userId))
166187
return string.Empty;
@@ -170,7 +191,16 @@ private async Task<string> OpenDmChannel(string token, string userId)
170191
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
171192
request.Content = payload;
172193
var response = await _httpClient.SendAsync(request);
173-
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
194+
SlackDmResponse? result;
195+
try
196+
{
197+
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
198+
}
199+
catch (JsonException ex)
200+
{
201+
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
202+
result = null;
203+
}
174204

175205
if (result is null)
176206
{

src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Bit.Core.Services;
1+
using Bit.Core.Models.Slack;
2+
using Bit.Core.Services;
23

34
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
45

@@ -24,9 +25,10 @@ public string GetRedirectUrl(string callbackUrl, string state)
2425
return string.Empty;
2526
}
2627

27-
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
28+
public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
29+
string channelId)
2830
{
29-
return Task.FromResult(0);
31+
return Task.FromResult<SlackSendMessageResponse?>(null);
3032
}
3133

3234
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)

test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,29 @@ await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1
133133
.DeleteAsync(organizationIntegration);
134134
}
135135

136+
[Theory, BitAutoData]
137+
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
138+
SutProvider<OrganizationIntegrationController> sutProvider,
139+
Guid organizationId,
140+
OrganizationIntegration organizationIntegration)
141+
{
142+
organizationIntegration.OrganizationId = organizationId;
143+
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
144+
sutProvider.GetDependency<ICurrentContext>()
145+
.OrganizationOwner(organizationId)
146+
.Returns(true);
147+
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
148+
.GetByIdAsync(Arg.Any<Guid>())
149+
.Returns(organizationIntegration);
150+
151+
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
152+
153+
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
154+
.GetByIdAsync(organizationIntegration.Id);
155+
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
156+
.DeleteAsync(organizationIntegration);
157+
}
158+
136159
[Theory, BitAutoData]
137160
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
138161
SutProvider<OrganizationIntegrationController> sutProvider,

0 commit comments

Comments
 (0)