316 lines
12 KiB
C#
316 lines
12 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
|
|
|
/// <summary>
|
|
/// Task 5: Tests for the EWS transport branch of the Email outbox delivery adapter —
|
|
/// the transport parse, the EWS config-shape validation (each failure permanent and
|
|
/// short-circuiting before the sender is touched), the request the sender receives,
|
|
/// the typed transient/permanent outcome mapping, and the proof that an unset or
|
|
/// "Smtp" transport still takes the untouched SMTP path.
|
|
/// </summary>
|
|
public class EmailNotificationDeliveryAdapterEwsTests
|
|
{
|
|
private const string Endpoint = "https://ews.example.test/ews/exchange.asmx";
|
|
|
|
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
|
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
|
|
private readonly IEwsMailSender _ewsSender = Substitute.For<IEwsMailSender>();
|
|
|
|
private EmailNotificationDeliveryAdapter CreateAdapter(bool withEwsSender = true)
|
|
{
|
|
return new EmailNotificationDeliveryAdapter(
|
|
_repository,
|
|
() => _smtpClient,
|
|
NullLogger<EmailNotificationDeliveryAdapter>.Instance,
|
|
tokenService: null,
|
|
options: null,
|
|
ewsMailSender: withEwsSender ? _ewsSender : null);
|
|
}
|
|
|
|
private static Notification MakeNotification(string listName = "ops-team")
|
|
{
|
|
return new Notification(
|
|
Guid.NewGuid().ToString(),
|
|
NotificationType.Email,
|
|
listName,
|
|
"Subject",
|
|
"Body",
|
|
"site-1");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wires a resolvable list with two recipients plus a single configuration row shaped
|
|
/// by the caller — the one knob every case below turns.
|
|
/// </summary>
|
|
private void SetupWithConfig(SmtpConfiguration config)
|
|
{
|
|
var list = new NotificationList("ops-team") { Id = 1 };
|
|
var recipients = new List<NotificationRecipient>
|
|
{
|
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
|
new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
|
|
};
|
|
|
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { config });
|
|
}
|
|
|
|
private static SmtpConfiguration EwsConfig(
|
|
string host = Endpoint,
|
|
string authType = "basic",
|
|
string? credentials = @"dom\svc:pw",
|
|
string transport = "Ews")
|
|
{
|
|
return new SmtpConfiguration(host, authType, "noreply@example.com")
|
|
{
|
|
Id = 1,
|
|
Transport = transport,
|
|
Credentials = credentials,
|
|
ConnectionTimeoutSeconds = 45
|
|
};
|
|
}
|
|
|
|
private static SmtpConfiguration SmtpConfig(string? transport)
|
|
{
|
|
return new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
|
{
|
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", Transport = transport
|
|
};
|
|
}
|
|
|
|
private EwsSendRequest CapturedRequest()
|
|
{
|
|
var call = _ewsSender.ReceivedCalls().Single();
|
|
return (EwsSendRequest)call.GetArguments()[0]!;
|
|
}
|
|
|
|
private async Task AssertSenderUntouched()
|
|
{
|
|
await _ewsSender.DidNotReceive().SendAsync(
|
|
Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_EwsTransport_SendsThroughEwsSenderAndReturnsSuccess()
|
|
{
|
|
SetupWithConfig(EwsConfig());
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
var request = CapturedRequest();
|
|
Assert.Equal(new Uri(Endpoint), request.Endpoint);
|
|
Assert.Equal(@"dom\svc", request.Username);
|
|
Assert.Equal("pw", request.Password);
|
|
Assert.Equal("noreply@example.com", request.FromAddress);
|
|
Assert.Equal(new[] { "alice@example.com", "bob@example.com" }, request.BccRecipients);
|
|
Assert.Equal("Subject", request.Subject);
|
|
Assert.Equal("Body", request.Body);
|
|
Assert.Equal(45, request.TimeoutSeconds);
|
|
|
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
|
Assert.Contains("alice@example.com", outcome.ResolvedTargets);
|
|
Assert.Contains("bob@example.com", outcome.ResolvedTargets);
|
|
Assert.Null(outcome.Error);
|
|
|
|
// The EWS branch must never open an SMTP connection.
|
|
await _smtpClient.DidNotReceive().ConnectAsync(
|
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
|
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_PasswordContainsColon_SplitsOnFirstColonOnly()
|
|
{
|
|
SetupWithConfig(EwsConfig(credentials: @"dom\svc:p:w"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
var request = CapturedRequest();
|
|
Assert.Equal(@"dom\svc", request.Username);
|
|
Assert.Equal("p:w", request.Password);
|
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_EwsTransientException_ReturnsTransient()
|
|
{
|
|
SetupWithConfig(EwsConfig());
|
|
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new EwsTransientException("503 server busy"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
|
Assert.Contains("server busy", outcome.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_EwsPermanentException_ReturnsPermanent()
|
|
{
|
|
SetupWithConfig(EwsConfig());
|
|
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new EwsPermanentException("401 unauthorized"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("unauthorized", outcome.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_UnclassifiedSenderException_ReturnsPermanentAndRedactsCredential()
|
|
{
|
|
SetupWithConfig(EwsConfig(credentials: @"dom\svc:super-secret-password"));
|
|
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException(
|
|
@"boom for dom\svc:super-secret-password"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.DoesNotContain("super-secret-password", outcome.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_CancelledToken_ThrowsOperationCanceledException()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var list = new NotificationList("ops-team") { Id = 1 };
|
|
_repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
|
|
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<NotificationRecipient>
|
|
{
|
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
|
});
|
|
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<SmtpConfiguration> { EwsConfig() });
|
|
_ewsSender.SendAsync(Arg.Any<EwsSendRequest>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new OperationCanceledException(cts.Token));
|
|
var adapter = CreateAdapter();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_NonHttpsEndpoint_ReturnsPermanentWithoutCallingSender()
|
|
{
|
|
SetupWithConfig(EwsConfig(host: "http://ews.example.test/ews/exchange.asmx"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("https", outcome.Error);
|
|
await AssertSenderUntouched();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_NonAbsoluteEndpoint_ReturnsPermanentWithoutCallingSender()
|
|
{
|
|
SetupWithConfig(EwsConfig(host: "ews.example.test"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("ews.example.test", outcome.Error);
|
|
await AssertSenderUntouched();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_NonBasicAuthType_ReturnsPermanentWithoutCallingSender()
|
|
{
|
|
SetupWithConfig(EwsConfig(authType: "oauth2"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("Basic", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
|
await AssertSenderUntouched();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("no-colon-here")]
|
|
[InlineData(":pw")]
|
|
[InlineData(@"dom\svc:")]
|
|
public async Task Deliver_MalformedCredentials_ReturnsPermanentWithoutCallingSender(
|
|
string? credentials)
|
|
{
|
|
SetupWithConfig(EwsConfig(credentials: credentials));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("username:password", outcome.Error);
|
|
await AssertSenderUntouched();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_UnknownTransport_ReturnsPermanentWithoutCallingSender()
|
|
{
|
|
SetupWithConfig(EwsConfig(transport: "Graph"));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("Graph", outcome.Error);
|
|
await AssertSenderUntouched();
|
|
await _smtpClient.DidNotReceive().ConnectAsync(
|
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<SmtpTlsMode>(),
|
|
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deliver_NoEwsSenderRegistered_ReturnsPermanent()
|
|
{
|
|
SetupWithConfig(EwsConfig());
|
|
var adapter = CreateAdapter(withEwsSender: false);
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
|
Assert.Contains("EWS", outcome.Error);
|
|
Assert.Contains("registered", outcome.Error);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("Smtp")]
|
|
public async Task Deliver_SmtpOrUnsetTransport_TakesSmtpPath(string? transport)
|
|
{
|
|
SetupWithConfig(SmtpConfig(transport));
|
|
var adapter = CreateAdapter();
|
|
|
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
|
|
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
|
await _smtpClient.Received().ConnectAsync(
|
|
"smtp.example.com", 587, SmtpTlsMode.StartTls,
|
|
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
await AssertSenderUntouched();
|
|
}
|
|
}
|