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; /// /// 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. /// public class EmailNotificationDeliveryAdapterEwsTests { private const string Endpoint = "https://ews.example.test/ews/exchange.asmx"; private readonly INotificationRepository _repository = Substitute.For(); private readonly ISmtpClientWrapper _smtpClient = Substitute.For(); private readonly IEwsMailSender _ewsSender = Substitute.For(); private EmailNotificationDeliveryAdapter CreateAdapter(bool withEwsSender = true) { return new EmailNotificationDeliveryAdapter( _repository, () => _smtpClient, NullLogger.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"); } /// /// Wires a resolvable list with two recipients plus a single configuration row shaped /// by the caller — the one knob every case below turns. /// private void SetupWithConfig(SmtpConfiguration config) { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { 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 { 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(), Arg.Any()); } [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [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(), Arg.Any()) .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(), Arg.Any()) .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(), Arg.Any()) .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()).Returns(list); _repository.GetRecipientsByListIdAsync(1, Arg.Any()) .Returns(new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }); _repository.GetAllSmtpConfigurationsAsync(Arg.Any()) .Returns(new List { EwsConfig() }); _ewsSender.SendAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new OperationCanceledException(cts.Token)); var adapter = CreateAdapter(); await Assert.ThrowsAnyAsync( () => 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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [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(), Arg.Any()); await AssertSenderUntouched(); } }