Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterEwsTests.cs
T

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();
}
}