mirror of
https://github.com/bitwarden/server
synced 2026-01-19 00:43:47 +00:00
Merge branch 'main' into tools/pm-21918/send-authentication-commands
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categories of event integration failures used for classification and retry logic.
|
||||||
|
/// </summary>
|
||||||
|
public enum IntegrationFailureCategory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Service is temporarily unavailable (503, upstream outage, maintenance).
|
||||||
|
/// </summary>
|
||||||
|
ServiceUnavailable,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authentication failed (401, 403, invalid_auth, token issues).
|
||||||
|
/// </summary>
|
||||||
|
AuthenticationFailed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration error (invalid config, channel_not_found, etc.).
|
||||||
|
/// </summary>
|
||||||
|
ConfigurationError,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate limited (429, rate_limited).
|
||||||
|
/// </summary>
|
||||||
|
RateLimited,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transient error (timeouts, 500, network errors).
|
||||||
|
/// </summary>
|
||||||
|
TransientError,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent failure unrelated to authentication/config (e.g., unrecoverable payload/format issue).
|
||||||
|
/// </summary>
|
||||||
|
PermanentFailure
|
||||||
|
}
|
||||||
@@ -1,16 +1,84 @@
|
|||||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of an integration handler operation, including success status,
|
||||||
|
/// failure categorization, and retry metadata. Use the <see cref="Succeed"/> factory method
|
||||||
|
/// for successful operations or <see cref="Fail"/> for failures with automatic retry-ability
|
||||||
|
/// determination based on the failure category.
|
||||||
|
/// </summary>
|
||||||
public class IntegrationHandlerResult
|
public class IntegrationHandlerResult
|
||||||
{
|
{
|
||||||
public IntegrationHandlerResult(bool success, IIntegrationMessage message)
|
/// <summary>
|
||||||
|
/// True if the integration send succeeded, false otherwise.
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The integration message that was processed.
|
||||||
|
/// </summary>
|
||||||
|
public IIntegrationMessage Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional UTC date/time indicating when a failed operation should be retried.
|
||||||
|
/// Will be used by the retry queue to delay re-sending the message.
|
||||||
|
/// Usually set based on the Retry-After header from rate-limited responses.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DelayUntilDate { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category of the failure. Null for successful results.
|
||||||
|
/// </summary>
|
||||||
|
public IntegrationFailureCategory? Category { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detailed failure reason or error message. Empty for successful results.
|
||||||
|
/// </summary>
|
||||||
|
public string? FailureReason { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the operation is retryable.
|
||||||
|
/// Computed from the failure category.
|
||||||
|
/// </summary>
|
||||||
|
public bool Retryable => Category switch
|
||||||
|
{
|
||||||
|
IntegrationFailureCategory.RateLimited => true,
|
||||||
|
IntegrationFailureCategory.TransientError => true,
|
||||||
|
IntegrationFailureCategory.ServiceUnavailable => true,
|
||||||
|
IntegrationFailureCategory.AuthenticationFailed => false,
|
||||||
|
IntegrationFailureCategory.ConfigurationError => false,
|
||||||
|
IntegrationFailureCategory.PermanentFailure => false,
|
||||||
|
null => false,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a successful result.
|
||||||
|
/// </summary>
|
||||||
|
public static IntegrationHandlerResult Succeed(IIntegrationMessage message)
|
||||||
|
{
|
||||||
|
return new IntegrationHandlerResult(success: true, message: message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a failed result with a failure category and reason.
|
||||||
|
/// </summary>
|
||||||
|
public static IntegrationHandlerResult Fail(
|
||||||
|
IIntegrationMessage message,
|
||||||
|
IntegrationFailureCategory category,
|
||||||
|
string failureReason,
|
||||||
|
DateTime? delayUntil = null)
|
||||||
|
{
|
||||||
|
return new IntegrationHandlerResult(success: false, message: message)
|
||||||
|
{
|
||||||
|
Category = category,
|
||||||
|
FailureReason = failureReason,
|
||||||
|
DelayUntilDate = delayUntil
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntegrationHandlerResult(bool success, IIntegrationMessage message)
|
||||||
{
|
{
|
||||||
Success = success;
|
Success = success;
|
||||||
Message = message;
|
Message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Success { get; set; } = false;
|
|
||||||
public bool Retryable { get; set; } = false;
|
|
||||||
public IIntegrationMessage Message { get; set; }
|
|
||||||
public DateTime? DelayUntilDate { get; set; }
|
|
||||||
public string FailureReason { get; set; } = string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
|||||||
ICollection<OrganizationUser> allOrgUsers, User user)
|
ICollection<OrganizationUser> allOrgUsers, User user)
|
||||||
{
|
{
|
||||||
var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||||
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user)))
|
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId,
|
||||||
|
allOrgUsers.Append(orgUser),
|
||||||
|
user)))
|
||||||
.Match(
|
.Match(
|
||||||
error => error.Message,
|
error => error.Message,
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public class AutomaticUserConfirmationPolicyEnforcementValidator(
|
|||||||
|
|
||||||
var currentOrganizationUser = request.AllOrganizationUsers
|
var currentOrganizationUser = request.AllOrganizationUsers
|
||||||
.FirstOrDefault(x => x.OrganizationId == request.OrganizationId
|
.FirstOrDefault(x => x.OrganizationId == request.OrganizationId
|
||||||
&& x.UserId == request.User.Id);
|
// invited users do not have a userId but will have email
|
||||||
|
&& (x.UserId == request.User.Id || x.Email == request.User.Email));
|
||||||
|
|
||||||
if (currentOrganizationUser is null)
|
if (currentOrganizationUser is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,46 +29,87 @@ public abstract class IntegrationHandlerBase<T> : IIntegrationHandler<T>
|
|||||||
IntegrationMessage<T> message,
|
IntegrationMessage<T> message,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
|
if (response.IsSuccessStatusCode)
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode) return result;
|
|
||||||
|
|
||||||
switch (response.StatusCode)
|
|
||||||
{
|
{
|
||||||
case HttpStatusCode.TooManyRequests:
|
return IntegrationHandlerResult.Succeed(message);
|
||||||
case HttpStatusCode.RequestTimeout:
|
|
||||||
case HttpStatusCode.InternalServerError:
|
|
||||||
case HttpStatusCode.BadGateway:
|
|
||||||
case HttpStatusCode.ServiceUnavailable:
|
|
||||||
case HttpStatusCode.GatewayTimeout:
|
|
||||||
result.Retryable = true;
|
|
||||||
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}";
|
|
||||||
|
|
||||||
if (response.Headers.TryGetValues("Retry-After", out var values))
|
|
||||||
{
|
|
||||||
var value = values.FirstOrDefault();
|
|
||||||
if (int.TryParse(value, out var seconds))
|
|
||||||
{
|
|
||||||
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
|
|
||||||
result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
|
|
||||||
}
|
|
||||||
else if (DateTimeOffset.TryParseExact(value,
|
|
||||||
"r", // "r" is the round-trip format: RFC1123
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
|
||||||
out var retryDate))
|
|
||||||
{
|
|
||||||
// Retry-after was specified as a date. Adjust DelayUntilDate to the specified date.
|
|
||||||
result.DelayUntilDate = retryDate.UtcDateTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
result.Retryable = false;
|
|
||||||
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
var category = ClassifyHttpStatusCode(response.StatusCode);
|
||||||
|
var failureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
|
||||||
|
|
||||||
|
if (category is not (IntegrationFailureCategory.RateLimited
|
||||||
|
or IntegrationFailureCategory.TransientError
|
||||||
|
or IntegrationFailureCategory.ServiceUnavailable) ||
|
||||||
|
!response.Headers.TryGetValues("Retry-After", out var values)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return IntegrationHandlerResult.Fail(message: message, category: category, failureReason: failureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Retry-After header for rate-limited and retryable errors
|
||||||
|
DateTime? delayUntil = null;
|
||||||
|
var value = values.FirstOrDefault();
|
||||||
|
if (int.TryParse(value, out var seconds))
|
||||||
|
{
|
||||||
|
// Retry-after was specified in seconds
|
||||||
|
delayUntil = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
|
||||||
|
}
|
||||||
|
else if (DateTimeOffset.TryParseExact(value,
|
||||||
|
"r", // "r" is the round-trip format: RFC1123
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||||
|
out var retryDate))
|
||||||
|
{
|
||||||
|
// Retry-after was specified as a date
|
||||||
|
delayUntil = retryDate.UtcDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
category,
|
||||||
|
failureReason,
|
||||||
|
delayUntil
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies an <see cref="HttpStatusCode"/> as an <see cref="IntegrationFailureCategory"/> to drive
|
||||||
|
/// retry behavior and operator-facing failure reporting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statusCode">The HTTP status code.</param>
|
||||||
|
/// <returns>The corresponding <see cref="IntegrationFailureCategory"/>.</returns>
|
||||||
|
protected static IntegrationFailureCategory ClassifyHttpStatusCode(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
var explicitCategory = statusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Unauthorized => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
HttpStatusCode.Forbidden => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
HttpStatusCode.NotFound => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
HttpStatusCode.Gone => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
HttpStatusCode.MovedPermanently => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
HttpStatusCode.TemporaryRedirect => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
HttpStatusCode.PermanentRedirect => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
HttpStatusCode.TooManyRequests => IntegrationFailureCategory.RateLimited,
|
||||||
|
HttpStatusCode.RequestTimeout => IntegrationFailureCategory.TransientError,
|
||||||
|
HttpStatusCode.InternalServerError => IntegrationFailureCategory.TransientError,
|
||||||
|
HttpStatusCode.BadGateway => IntegrationFailureCategory.TransientError,
|
||||||
|
HttpStatusCode.GatewayTimeout => IntegrationFailureCategory.TransientError,
|
||||||
|
HttpStatusCode.ServiceUnavailable => IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
HttpStatusCode.NotImplemented => IntegrationFailureCategory.PermanentFailure,
|
||||||
|
_ => (IntegrationFailureCategory?)null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (explicitCategory is not null)
|
||||||
|
{
|
||||||
|
return explicitCategory.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)statusCode switch
|
||||||
|
{
|
||||||
|
>= 300 and <= 399 => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
>= 400 and <= 499 => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
>= 500 and <= 599 => IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
_ => IntegrationFailureCategory.ServiceUnavailable
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ public class AzureServiceBusIntegrationListenerService<TConfiguration> : Backgro
|
|||||||
{
|
{
|
||||||
// Non-recoverable failure or exceeded the max number of retries
|
// Non-recoverable failure or exceeded the max number of retries
|
||||||
// Return false to indicate this message should be dead-lettered
|
// Return false to indicate this message should be dead-lettered
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Integration failure - non-recoverable error or max retries exceeded. " +
|
||||||
|
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||||
|
"FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}",
|
||||||
|
message.MessageId,
|
||||||
|
message.IntegrationType,
|
||||||
|
message.OrganizationId,
|
||||||
|
result.Category,
|
||||||
|
result.FailureReason,
|
||||||
|
message.RetryCount,
|
||||||
|
_maxRetries);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,14 +106,32 @@ public class RabbitMqIntegrationListenerService<TConfiguration> : BackgroundServ
|
|||||||
{
|
{
|
||||||
// Exceeded the max number of retries; fail and send to dead letter queue
|
// Exceeded the max number of retries; fail and send to dead letter queue
|
||||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||||
_logger.LogWarning("Max retry attempts reached. Sent to DLQ.");
|
_logger.LogWarning(
|
||||||
|
"Integration failure - max retries exceeded. " +
|
||||||
|
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||||
|
"FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}",
|
||||||
|
message.MessageId,
|
||||||
|
message.IntegrationType,
|
||||||
|
message.OrganizationId,
|
||||||
|
result.Category,
|
||||||
|
result.FailureReason,
|
||||||
|
message.RetryCount,
|
||||||
|
_maxRetries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries
|
// Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries
|
||||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||||
_logger.LogWarning("Non-retryable failure. Sent to DLQ.");
|
_logger.LogWarning(
|
||||||
|
"Integration failure - non-retryable. " +
|
||||||
|
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||||
|
"FailureCategory: {Category}, Reason: {Reason}",
|
||||||
|
message.MessageId,
|
||||||
|
message.IntegrationType,
|
||||||
|
message.OrganizationId,
|
||||||
|
result.Category,
|
||||||
|
result.FailureReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message has been sent to retry or dead letter queues.
|
// Message has been sent to retry or dead letter queues.
|
||||||
|
|||||||
@@ -6,15 +6,6 @@ public class SlackIntegrationHandler(
|
|||||||
ISlackService slackService)
|
ISlackService slackService)
|
||||||
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
|
: 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)
|
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
|
||||||
{
|
{
|
||||||
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
|
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
|
||||||
@@ -25,24 +16,61 @@ public class SlackIntegrationHandler(
|
|||||||
|
|
||||||
if (slackResponse is null)
|
if (slackResponse is null)
|
||||||
{
|
{
|
||||||
return new IntegrationHandlerResult(success: false, message: message)
|
return IntegrationHandlerResult.Fail(
|
||||||
{
|
message,
|
||||||
FailureReason = "Slack response was null"
|
IntegrationFailureCategory.TransientError,
|
||||||
};
|
"Slack response was null"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slackResponse.Ok)
|
if (slackResponse.Ok)
|
||||||
{
|
{
|
||||||
return new IntegrationHandlerResult(success: true, message: message);
|
return IntegrationHandlerResult.Succeed(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
|
var category = ClassifySlackError(slackResponse.Error);
|
||||||
|
return IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
category,
|
||||||
|
slackResponse.Error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (_retryableErrors.Contains(slackResponse.Error))
|
/// <summary>
|
||||||
|
/// Classifies a Slack API error code string as an <see cref="IntegrationFailureCategory"/> to drive
|
||||||
|
/// retry behavior and operator-facing failure reporting.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Slack responses commonly return an <c>error</c> string when <c>ok</c> is false. This method maps
|
||||||
|
/// known Slack error codes to failure categories.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Any unrecognized error codes default to <see cref="IntegrationFailureCategory.TransientError"/> to avoid
|
||||||
|
/// incorrectly marking new/unknown Slack failures as non-retryable.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="error">The Slack error code string (e.g. <c>invalid_auth</c>, <c>rate_limited</c>).</param>
|
||||||
|
/// <returns>The corresponding <see cref="IntegrationFailureCategory"/>.</returns>
|
||||||
|
private static IntegrationFailureCategory ClassifySlackError(string error)
|
||||||
|
{
|
||||||
|
return error switch
|
||||||
{
|
{
|
||||||
result.Retryable = true;
|
"invalid_auth" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
}
|
"access_denied" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
"token_expired" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
return result;
|
"token_revoked" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
"account_inactive" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
"not_authed" => IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
"channel_not_found" => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
"is_archived" => IntegrationFailureCategory.ConfigurationError,
|
||||||
|
"rate_limited" => IntegrationFailureCategory.RateLimited,
|
||||||
|
"ratelimited" => IntegrationFailureCategory.RateLimited,
|
||||||
|
"message_limit_exceeded" => IntegrationFailureCategory.RateLimited,
|
||||||
|
"internal_error" => IntegrationFailureCategory.TransientError,
|
||||||
|
"service_unavailable" => IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
"fatal_error" => IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
_ => IntegrationFailureCategory.TransientError
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using System.Text.Json;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
using Microsoft.Rest;
|
using Microsoft.Rest;
|
||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
@@ -18,24 +19,48 @@ public class TeamsIntegrationHandler(
|
|||||||
channelId: message.Configuration.ChannelId
|
channelId: message.Configuration.ChannelId
|
||||||
);
|
);
|
||||||
|
|
||||||
return new IntegrationHandlerResult(success: true, message: message);
|
return IntegrationHandlerResult.Succeed(message);
|
||||||
}
|
}
|
||||||
catch (HttpOperationException ex)
|
catch (HttpOperationException ex)
|
||||||
{
|
{
|
||||||
var result = new IntegrationHandlerResult(success: false, message: message);
|
var category = ClassifyHttpStatusCode(ex.Response.StatusCode);
|
||||||
var statusCode = (int)ex.Response.StatusCode;
|
return IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = statusCode is 429 or >= 500 and < 600;
|
message,
|
||||||
result.FailureReason = ex.Message;
|
category,
|
||||||
|
ex.Message
|
||||||
return result;
|
);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
ex.Message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (UriFormatException ex)
|
||||||
|
{
|
||||||
|
return IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
ex.Message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.PermanentFailure,
|
||||||
|
ex.Message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var result = new IntegrationHandlerResult(success: false, message: message);
|
return IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = false;
|
message,
|
||||||
result.FailureReason = ex.Message;
|
IntegrationFailureCategory.TransientError,
|
||||||
|
ex.Message
|
||||||
return result;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,7 +671,6 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
|
|
||||||
customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
||||||
customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
|
customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
|
||||||
customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
|
|
||||||
customResponse.Add("Kdf", (byte)user.Kdf);
|
customResponse.Add("Kdf", (byte)user.Kdf);
|
||||||
customResponse.Add("KdfIterations", user.KdfIterations);
|
customResponse.Add("KdfIterations", user.KdfIterations);
|
||||||
customResponse.Add("KdfMemory", user.KdfMemory);
|
customResponse.Add("KdfMemory", user.KdfMemory);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Bit.Core;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.IdentityServer;
|
using Bit.Core.Auth.IdentityServer;
|
||||||
using Bit.Core.Auth.Models.Api.Response;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
@@ -155,23 +154,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
{
|
{
|
||||||
// KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it
|
// KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it
|
||||||
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
|
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
|
||||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key connector data should have already been set in the decryption options
|
|
||||||
// for backwards compatibility we set them this way too. We can eventually get rid of this once we clean up
|
|
||||||
// ResetMasterPassword
|
|
||||||
if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) ||
|
|
||||||
userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userDecryptionOptions is { KeyConnectorOption: { } })
|
|
||||||
{
|
|
||||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations;
|
||||||
|
|
||||||
|
public class IntegrationHandlerResultTests
|
||||||
|
{
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Succeed_SetsSuccessTrue_CategoryNull(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Succeed(message);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.Category);
|
||||||
|
Assert.Equal(message, result.Message);
|
||||||
|
Assert.Null(result.FailureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Fail_WithCategory_SetsSuccessFalse_CategorySet(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var category = IntegrationFailureCategory.AuthenticationFailed;
|
||||||
|
var failureReason = "Invalid credentials";
|
||||||
|
|
||||||
|
var result = IntegrationHandlerResult.Fail(message, category, failureReason);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(category, result.Category);
|
||||||
|
Assert.Equal(failureReason, result.FailureReason);
|
||||||
|
Assert.Equal(message, result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Fail_WithDelayUntil_SetsDelayUntilDate(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var delayUntil = DateTime.UtcNow.AddMinutes(5);
|
||||||
|
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.RateLimited,
|
||||||
|
"Rate limited",
|
||||||
|
delayUntil
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(delayUntil, result.DelayUntilDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_RateLimited_ReturnsTrue(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.RateLimited,
|
||||||
|
"Rate limited"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_TransientError_ReturnsTrue(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.TransientError,
|
||||||
|
"Temporary network issue"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_AuthenticationFailed_ReturnsFalse(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
"Invalid token"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_ConfigurationError_ReturnsFalse(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
"Channel not found"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_ServiceUnavailable_ReturnsTrue(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
"Service is down"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_PermanentFailure_ReturnsFalse(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Fail(
|
||||||
|
message,
|
||||||
|
IntegrationFailureCategory.PermanentFailure,
|
||||||
|
"Permanent failure"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public void Retryable_SuccessCase_ReturnsFalse(IntegrationMessage message)
|
||||||
|
{
|
||||||
|
var result = IntegrationHandlerResult.Succeed(message);
|
||||||
|
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,10 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.RetryCount = 0;
|
message.RetryCount = 0;
|
||||||
|
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = false;
|
message: message,
|
||||||
|
category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -89,6 +91,12 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
|
|
||||||
await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson()));
|
await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson()));
|
||||||
await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any<IIntegrationMessage>());
|
await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any<IIntegrationMessage>());
|
||||||
|
_logger.Received().Log(
|
||||||
|
LogLevel.Warning,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")),
|
||||||
|
Arg.Any<Exception?>(),
|
||||||
|
Arg.Any<Func<object, Exception?, string>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -96,9 +104,10 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.RetryCount = _config.MaxRetries;
|
message.RetryCount = _config.MaxRetries;
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = true;
|
message: message,
|
||||||
|
category: IntegrationFailureCategory.TransientError, // Retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -108,6 +117,12 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
|
|
||||||
await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson()));
|
await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson()));
|
||||||
await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any<IIntegrationMessage>());
|
await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any<IIntegrationMessage>());
|
||||||
|
_logger.Received().Log(
|
||||||
|
LogLevel.Warning,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")),
|
||||||
|
Arg.Any<Exception?>(),
|
||||||
|
Arg.Any<Func<object, Exception?, string>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -116,8 +131,10 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.RetryCount = 0;
|
message.RetryCount = 0;
|
||||||
|
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = true;
|
message: message,
|
||||||
|
category: IntegrationFailureCategory.TransientError, // Retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -133,7 +150,7 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage<WebhookIntegrationConfiguration> message)
|
public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage<WebhookIntegrationConfiguration> message)
|
||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
var result = new IntegrationHandlerResult(true, message);
|
var result = IntegrationHandlerResult.Succeed(message);
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -156,7 +173,7 @@ public class AzureServiceBusIntegrationListenerServiceTests
|
|||||||
_logger.Received(1).Log(
|
_logger.Received(1).Log(
|
||||||
LogLevel.Error,
|
LogLevel.Error,
|
||||||
Arg.Any<EventId>(),
|
Arg.Any<EventId>(),
|
||||||
Arg.Any<object>(),
|
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Unhandled error processing ASB message")),
|
||||||
Arg.Any<Exception>(),
|
Arg.Any<Exception>(),
|
||||||
Arg.Any<Func<object, Exception?, string>>());
|
Arg.Any<Func<object, Exception?, string>>());
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class DatadogIntegrationHandlerTests
|
|||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
Assert.Empty(result.FailureReason);
|
Assert.Null(result.FailureReason);
|
||||||
|
|
||||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
||||||
Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName))
|
Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using System.Net;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -7,7 +8,6 @@ namespace Bit.Core.Test.Services;
|
|||||||
|
|
||||||
public class IntegrationHandlerTests
|
public class IntegrationHandlerTests
|
||||||
{
|
{
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_ConvertsJsonToTypedIntegrationMessage()
|
public async Task HandleAsync_ConvertsJsonToTypedIntegrationMessage()
|
||||||
{
|
{
|
||||||
@@ -33,13 +33,113 @@ public class IntegrationHandlerTests
|
|||||||
Assert.Equal(expected.IntegrationType, typedResult.IntegrationType);
|
Assert.Equal(expected.IntegrationType, typedResult.IntegrationType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized)]
|
||||||
|
[InlineData(HttpStatusCode.Forbidden)]
|
||||||
|
public void ClassifyHttpStatusCode_AuthenticationFailed(HttpStatusCode code)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.AuthenticationFailed,
|
||||||
|
TestIntegrationHandler.Classify(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.NotFound)]
|
||||||
|
[InlineData(HttpStatusCode.Gone)]
|
||||||
|
[InlineData(HttpStatusCode.MovedPermanently)]
|
||||||
|
[InlineData(HttpStatusCode.TemporaryRedirect)]
|
||||||
|
[InlineData(HttpStatusCode.PermanentRedirect)]
|
||||||
|
public void ClassifyHttpStatusCode_ConfigurationError(HttpStatusCode code)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
TestIntegrationHandler.Classify(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_TooManyRequests_IsRateLimited()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.RateLimited,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.TooManyRequests));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_RequestTimeout_IsTransient()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.TransientError,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.RequestTimeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError)]
|
||||||
|
[InlineData(HttpStatusCode.BadGateway)]
|
||||||
|
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||||
|
public void ClassifyHttpStatusCode_Common5xx_AreTransient(HttpStatusCode code)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.TransientError,
|
||||||
|
TestIntegrationHandler.Classify(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_ServiceUnavailable_IsServiceUnavailable()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.ServiceUnavailable));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_NotImplemented_IsPermanentFailure()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.PermanentFailure,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.NotImplemented));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FClassifyHttpStatusCode_Unhandled3xx_IsConfigurationError()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.Found));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_Unhandled4xx_IsConfigurationError()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ConfigurationError,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_Unhandled5xx_IsServiceUnavailable()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
TestIntegrationHandler.Classify(HttpStatusCode.HttpVersionNotSupported));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHttpStatusCode_UnknownCode_DefaultsToServiceUnavailable()
|
||||||
|
{
|
||||||
|
// cast an out-of-range value to ensure default path is stable
|
||||||
|
Assert.Equal(
|
||||||
|
IntegrationFailureCategory.ServiceUnavailable,
|
||||||
|
TestIntegrationHandler.Classify((HttpStatusCode)799));
|
||||||
|
}
|
||||||
|
|
||||||
private class TestIntegrationHandler : IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
|
private class TestIntegrationHandler : IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
|
||||||
{
|
{
|
||||||
public override Task<IntegrationHandlerResult> HandleAsync(
|
public override Task<IntegrationHandlerResult> HandleAsync(
|
||||||
IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
|
IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
|
||||||
{
|
{
|
||||||
var result = new IntegrationHandlerResult(success: true, message: message);
|
return Task.FromResult(IntegrationHandlerResult.Succeed(message: message));
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IntegrationFailureCategory Classify(HttpStatusCode code) => ClassifyHttpStatusCode(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
new BasicProperties(),
|
new BasicProperties(),
|
||||||
body: Encoding.UTF8.GetBytes(message.ToJson())
|
body: Encoding.UTF8.GetBytes(message.ToJson())
|
||||||
);
|
);
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = false;
|
message: message,
|
||||||
|
category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -105,7 +107,7 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
_logger.Received().Log(
|
_logger.Received().Log(
|
||||||
LogLevel.Warning,
|
LogLevel.Warning,
|
||||||
Arg.Any<EventId>(),
|
Arg.Any<EventId>(),
|
||||||
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Non-retryable failure")),
|
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Integration failure - non-retryable.")),
|
||||||
Arg.Any<Exception?>(),
|
Arg.Any<Exception?>(),
|
||||||
Arg.Any<Func<object, Exception?, string>>());
|
Arg.Any<Func<object, Exception?, string>>());
|
||||||
|
|
||||||
@@ -133,8 +135,10 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
new BasicProperties(),
|
new BasicProperties(),
|
||||||
body: Encoding.UTF8.GetBytes(message.ToJson())
|
body: Encoding.UTF8.GetBytes(message.ToJson())
|
||||||
);
|
);
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = true;
|
message: message,
|
||||||
|
category: IntegrationFailureCategory.TransientError, // Retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -151,7 +155,7 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
_logger.Received().Log(
|
_logger.Received().Log(
|
||||||
LogLevel.Warning,
|
LogLevel.Warning,
|
||||||
Arg.Any<EventId>(),
|
Arg.Any<EventId>(),
|
||||||
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Max retry attempts reached")),
|
Arg.Is<object>(o => (o.ToString() ?? "").Contains("Integration failure - max retries exceeded.")),
|
||||||
Arg.Any<Exception?>(),
|
Arg.Any<Exception?>(),
|
||||||
Arg.Any<Func<object, Exception?, string>>());
|
Arg.Any<Func<object, Exception?, string>>());
|
||||||
|
|
||||||
@@ -179,9 +183,10 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
new BasicProperties(),
|
new BasicProperties(),
|
||||||
body: Encoding.UTF8.GetBytes(message.ToJson())
|
body: Encoding.UTF8.GetBytes(message.ToJson())
|
||||||
);
|
);
|
||||||
var result = new IntegrationHandlerResult(false, message);
|
var result = IntegrationHandlerResult.Fail(
|
||||||
result.Retryable = true;
|
message: message,
|
||||||
result.DelayUntilDate = _now.AddMinutes(1);
|
category: IntegrationFailureCategory.TransientError, // Retryable
|
||||||
|
failureReason: "403");
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
|
||||||
@@ -220,7 +225,7 @@ public class RabbitMqIntegrationListenerServiceTests
|
|||||||
new BasicProperties(),
|
new BasicProperties(),
|
||||||
body: Encoding.UTF8.GetBytes(message.ToJson())
|
body: Encoding.UTF8.GetBytes(message.ToJson())
|
||||||
);
|
);
|
||||||
var result = new IntegrationHandlerResult(true, message);
|
var result = IntegrationHandlerResult.Succeed(message);
|
||||||
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
_handler.HandleAsync(Arg.Any<string>()).Returns(result);
|
||||||
|
|
||||||
await sutProvider.Sut.ProcessReceivedMessageAsync(eventArgs, cancellationToken);
|
await sutProvider.Sut.ProcessReceivedMessageAsync(eventArgs, cancellationToken);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ public class SlackIntegrationHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure()
|
public async Task HandleAsync_NullResponse_ReturnsRetryableFailure()
|
||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
|
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
|
||||||
@@ -126,7 +126,7 @@ public class SlackIntegrationHandlerTests
|
|||||||
var result = await sutProvider.Sut.HandleAsync(message);
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.False(result.Retryable);
|
Assert.True(result.Retryable); // Null response is classified as TransientError (retryable)
|
||||||
Assert.Equal("Slack response was null", result.FailureReason);
|
Assert.Equal("Slack response was null", result.FailureReason);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using System.Text.Json;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
@@ -42,9 +43,77 @@ public class TeamsIntegrationHandlerTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleAsync_ArgumentException_ReturnsConfigurationError(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITeamsService>()
|
||||||
|
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.ThrowsAsync(new ArgumentException("argument error"));
|
||||||
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category);
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
public async Task HandleAsync_JsonException_ReturnsPermanentFailure(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITeamsService>()
|
||||||
|
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.ThrowsAsync(new JsonException("JSON error"));
|
||||||
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(IntegrationFailureCategory.PermanentFailure, result.Category);
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleAsync_UriFormatException_ReturnsConfigurationError(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
|
{
|
||||||
|
var sutProvider = GetSutProvider();
|
||||||
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ITeamsService>()
|
||||||
|
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.ThrowsAsync(new UriFormatException("Bad URI"));
|
||||||
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category);
|
||||||
|
Assert.False(result.Retryable);
|
||||||
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||||
|
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task HandleAsync_HttpExceptionForbidden_ReturnsAuthenticationFailed(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
@@ -62,6 +131,7 @@ public class TeamsIntegrationHandlerTests
|
|||||||
var result = await sutProvider.Sut.HandleAsync(message);
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(IntegrationFailureCategory.AuthenticationFailed, result.Category);
|
||||||
Assert.False(result.Retryable);
|
Assert.False(result.Retryable);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
@@ -73,7 +143,7 @@ public class TeamsIntegrationHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
public async Task HandleAsync_HttpExceptionTooManyRequests_ReturnsRateLimited(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
@@ -92,6 +162,7 @@ public class TeamsIntegrationHandlerTests
|
|||||||
var result = await sutProvider.Sut.HandleAsync(message);
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal(IntegrationFailureCategory.RateLimited, result.Category);
|
||||||
Assert.True(result.Retryable);
|
Assert.True(result.Retryable);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
@@ -103,7 +174,7 @@ public class TeamsIntegrationHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
public async Task HandleAsync_UnknownException_ReturnsTransientError(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||||
{
|
{
|
||||||
var sutProvider = GetSutProvider();
|
var sutProvider = GetSutProvider();
|
||||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||||
@@ -114,7 +185,8 @@ public class TeamsIntegrationHandlerTests
|
|||||||
var result = await sutProvider.Sut.HandleAsync(message);
|
var result = await sutProvider.Sut.HandleAsync(message);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.False(result.Retryable);
|
Assert.Equal(IntegrationFailureCategory.TransientError, result.Category);
|
||||||
|
Assert.True(result.Retryable);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
|
|
||||||
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class WebhookIntegrationHandlerTests
|
|||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
Assert.Empty(result.FailureReason);
|
Assert.Null(result.FailureReason);
|
||||||
|
|
||||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
||||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName))
|
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName))
|
||||||
@@ -79,7 +79,7 @@ public class WebhookIntegrationHandlerTests
|
|||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal(result.Message, message);
|
Assert.Equal(result.Message, message);
|
||||||
Assert.Empty(result.FailureReason);
|
Assert.Null(result.FailureReason);
|
||||||
|
|
||||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
||||||
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName))
|
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName))
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ public class IdentityServerTests : IClassFixture<IdentityApplicationFactory>
|
|||||||
var root = body.RootElement;
|
var root = body.RootElement;
|
||||||
AssertRefreshTokenExists(root);
|
AssertRefreshTokenExists(root);
|
||||||
AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False);
|
AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False);
|
||||||
AssertHelper.AssertJsonProperty(root, "ResetMasterPassword", JsonValueKind.False);
|
|
||||||
var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32();
|
var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32();
|
||||||
Assert.Equal(0, kdf);
|
Assert.Equal(0, kdf);
|
||||||
var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32();
|
var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32();
|
||||||
|
|||||||
Reference in New Issue
Block a user