feat(notifications): EWS branch in the email delivery adapter
This commit is contained in:
+160
-6
@@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
@@ -18,6 +19,14 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|||||||
/// Transient) — the canonical central-side email delivery path. The prior
|
/// Transient) — the canonical central-side email delivery path. The prior
|
||||||
/// site-shaped <c>NotificationDeliveryService</c> was deleted with sites no longer
|
/// site-shaped <c>NotificationDeliveryService</c> was deleted with sites no longer
|
||||||
/// delivering notifications.
|
/// delivering notifications.
|
||||||
|
/// <para>
|
||||||
|
/// The configured <c>SmtpConfiguration.Transport</c> selects the submission mechanism:
|
||||||
|
/// <c>Smtp</c> (the default for every row that stores no transport) takes the SMTP path
|
||||||
|
/// above; <c>Ews</c> hands the message to <see cref="IEwsMailSender"/> instead — one
|
||||||
|
/// <c>CreateItem</c> SOAP call to an on-prem Exchange endpoint. List resolution, recipient
|
||||||
|
/// resolution, configuration selection and address validation are shared by both
|
||||||
|
/// transports; only TLS mode is SMTP-specific.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
||||||
{
|
{
|
||||||
@@ -26,6 +35,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
private readonly OAuth2TokenService? _tokenService;
|
private readonly OAuth2TokenService? _tokenService;
|
||||||
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
|
private readonly ILogger<EmailNotificationDeliveryAdapter> _logger;
|
||||||
private readonly NotificationOptions _options;
|
private readonly NotificationOptions _options;
|
||||||
|
private readonly IEwsMailSender? _ewsMailSender;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of <see cref="EmailNotificationDeliveryAdapter"/>.</summary>
|
/// <summary>Initializes a new instance of <see cref="EmailNotificationDeliveryAdapter"/>.</summary>
|
||||||
/// <param name="repository">Repository for resolving notification list recipients.</param>
|
/// <param name="repository">Repository for resolving notification list recipients.</param>
|
||||||
@@ -33,12 +43,18 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
/// <param name="logger">Logger instance.</param>
|
/// <param name="logger">Logger instance.</param>
|
||||||
/// <param name="tokenService">Optional OAuth2 token service for Microsoft 365 Client Credentials auth.</param>
|
/// <param name="tokenService">Optional OAuth2 token service for Microsoft 365 Client Credentials auth.</param>
|
||||||
/// <param name="options">Optional notification options providing documented fallback values.</param>
|
/// <param name="options">Optional notification options providing documented fallback values.</param>
|
||||||
|
/// <param name="ewsMailSender">
|
||||||
|
/// Optional EWS mail sender used when the selected configuration row sets the Ews transport.
|
||||||
|
/// Registered by <c>AddNotificationService()</c>; a host that omits that call leaves it null,
|
||||||
|
/// in which case an Ews row fails permanently rather than silently falling back to SMTP.
|
||||||
|
/// </param>
|
||||||
public EmailNotificationDeliveryAdapter(
|
public EmailNotificationDeliveryAdapter(
|
||||||
INotificationRepository repository,
|
INotificationRepository repository,
|
||||||
Func<ISmtpClientWrapper> smtpClientFactory,
|
Func<ISmtpClientWrapper> smtpClientFactory,
|
||||||
ILogger<EmailNotificationDeliveryAdapter> logger,
|
ILogger<EmailNotificationDeliveryAdapter> logger,
|
||||||
OAuth2TokenService? tokenService = null,
|
OAuth2TokenService? tokenService = null,
|
||||||
IOptions<NotificationOptions>? options = null)
|
IOptions<NotificationOptions>? options = null,
|
||||||
|
IEwsMailSender? ewsMailSender = null)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_smtpClientFactory = smtpClientFactory;
|
_smtpClientFactory = smtpClientFactory;
|
||||||
@@ -47,6 +63,7 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
// NotificationOptions supplies the documented fallback values used when a
|
// NotificationOptions supplies the documented fallback values used when a
|
||||||
// deployed SmtpConfiguration row leaves a field unset (non-positive).
|
// deployed SmtpConfiguration row leaves a field unset (non-positive).
|
||||||
_options = options?.Value ?? new NotificationOptions();
|
_options = options?.Value ?? new NotificationOptions();
|
||||||
|
_ewsMailSender = ewsMailSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -89,21 +106,42 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
smtpConfig.Id);
|
smtpConfig.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown TLS mode is a configuration error that retrying cannot fix —
|
// An unknown transport is a configuration error that retrying cannot fix.
|
||||||
// surface it as a permanent failure (SMTP TLS validation policy).
|
EmailTransport transport;
|
||||||
SmtpTlsMode tlsMode;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
transport = EmailTransportParser.Parse(smtpConfig.Transport);
|
||||||
}
|
}
|
||||||
catch (ArgumentException ex)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
|
"Email notification to list '{List}' has an invalid transport: {Reason}",
|
||||||
notification.ListName, ex.Message);
|
notification.ListName, ex.Message);
|
||||||
return DeliveryOutcome.Permanent(ex.Message);
|
return DeliveryOutcome.Permanent(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLS applies to the SMTP transport only — an EWS row's Port/TlsMode are unused.
|
||||||
|
// Parsed here, ahead of address validation, so the SMTP path's check order (and
|
||||||
|
// hence which reason surfaces for a row that is invalid in more than one way) is
|
||||||
|
// exactly what it was before the transport branch existed.
|
||||||
|
var tlsMode = SmtpTlsMode.None;
|
||||||
|
if (transport == EmailTransport.Smtp)
|
||||||
|
{
|
||||||
|
// An unknown TLS mode is a configuration error that retrying cannot fix —
|
||||||
|
// surface it as a permanent failure (SMTP TLS validation policy).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tlsMode = SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Email notification to list '{List}' has an invalid SMTP TLS mode: {Reason}",
|
||||||
|
notification.ListName, ex.Message);
|
||||||
|
return DeliveryOutcome.Permanent(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A malformed sender or recipient address cannot be fixed by retrying —
|
// A malformed sender or recipient address cannot be fixed by retrying —
|
||||||
// surface it as a permanent failure.
|
// surface it as a permanent failure.
|
||||||
var addressError = EmailAddressValidator.ValidateAddresses(
|
var addressError = EmailAddressValidator.ValidateAddresses(
|
||||||
@@ -121,6 +159,13 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
.Select(r => r.EmailAddress!)
|
.Select(r => r.EmailAddress!)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Everything above is transport-agnostic; the EWS branch takes over here.
|
||||||
|
if (transport == EmailTransport.Ews)
|
||||||
|
{
|
||||||
|
return await DeliverViaEwsAsync(
|
||||||
|
smtpConfig, recipientAddresses, notification, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
|
await SendAsync(smtpConfig, tlsMode, recipientAddresses,
|
||||||
@@ -165,6 +210,115 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delivers the plain-text BCC email through Exchange Web Services.
|
||||||
|
/// <para>
|
||||||
|
/// Every EWS-specific configuration defect (a non-HTTPS or relative endpoint, a non-Basic
|
||||||
|
/// auth type, a credential that is not <c>username:password</c>, or a missing sender
|
||||||
|
/// registration) is a permanent failure decided before the sender is touched — retrying
|
||||||
|
/// cannot fix a misconfigured row, and no half-formed request should reach Exchange.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private async Task<DeliveryOutcome> DeliverViaEwsAsync(
|
||||||
|
SmtpConfiguration config,
|
||||||
|
IReadOnlyList<string> recipientAddresses,
|
||||||
|
Notification notification,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|
||||||
|
|| endpoint.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
return PermanentConfigError(
|
||||||
|
"EWS transport requires an absolute https:// endpoint URL in Host; "
|
||||||
|
+ $"got '{config.Host}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic is the only credential shape the sender builds an Authorization header for;
|
||||||
|
// OAuth2/NTLM are documented follow-ons, not silent fallbacks.
|
||||||
|
if (!config.AuthType.Equals("basic", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return PermanentConfigError(
|
||||||
|
$"EWS transport supports only Basic authentication; got '{config.AuthType}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on the FIRST colon only: a domain-qualified user name has none, but a
|
||||||
|
// password legitimately may contain one.
|
||||||
|
var parts = config.Credentials?.Split(':', 2) ?? [];
|
||||||
|
if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
|
||||||
|
{
|
||||||
|
return PermanentConfigError("EWS credentials must be in 'username:password' form.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ewsMailSender == null)
|
||||||
|
{
|
||||||
|
return PermanentConfigError(
|
||||||
|
"EWS transport configured but no EWS sender is registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
|
||||||
|
? config.ConnectionTimeoutSeconds
|
||||||
|
: _options.ConnectionTimeoutSeconds;
|
||||||
|
var request = new EwsSendRequest(
|
||||||
|
endpoint,
|
||||||
|
parts[0],
|
||||||
|
parts[1],
|
||||||
|
config.FromAddress,
|
||||||
|
recipientAddresses,
|
||||||
|
notification.Subject,
|
||||||
|
notification.Body,
|
||||||
|
timeoutSeconds);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ewsMailSender.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
return DeliveryOutcome.Success(string.Join(", ", recipientAddresses));
|
||||||
|
}
|
||||||
|
catch (EwsPermanentException ex)
|
||||||
|
{
|
||||||
|
// Auth, wrong URL, schema fault or recipient rejection — not retried.
|
||||||
|
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||||
|
_logger.LogError(
|
||||||
|
"Permanent EWS failure delivering email to list '{List}': {Detail}",
|
||||||
|
notification.ListName, detail);
|
||||||
|
return DeliveryOutcome.Permanent(detail);
|
||||||
|
}
|
||||||
|
catch (EwsTransientException ex)
|
||||||
|
{
|
||||||
|
// Availability-shaped failure (network/timeout/5xx/server-busy) — retried.
|
||||||
|
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Transient EWS failure delivering email to list '{List}': {Detail}",
|
||||||
|
notification.ListName, detail);
|
||||||
|
return DeliveryOutcome.Transient(detail);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// A caller-requested cancellation propagates; it is neither a success
|
||||||
|
// nor a delivery failure.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Anything the sender did not classify is treated as permanent, mirroring the
|
||||||
|
// SMTP path's default-to-permanent stance.
|
||||||
|
var detail = CredentialRedactor.Scrub(ex.Message, config.Credentials);
|
||||||
|
_logger.LogError(
|
||||||
|
"Unclassified failure delivering email via EWS to list '{List}' ({ExceptionType}): {Detail}",
|
||||||
|
notification.ListName, ex.GetType().Name, detail);
|
||||||
|
return DeliveryOutcome.Permanent($"Email delivery failed: {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local helper: one log-and-park shape for every EWS configuration defect.
|
||||||
|
DeliveryOutcome PermanentConfigError(string reason)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Email notification to list '{List}' has an invalid EWS configuration: {Reason}",
|
||||||
|
notification.ListName, reason);
|
||||||
|
return DeliveryOutcome.Permanent(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
|
/// Delivers the plain-text BCC email via SMTP. A permanent failure surfaces as
|
||||||
/// <see cref="SmtpPermanentException"/>; transient failures propagate for the
|
/// <see cref="SmtpPermanentException"/>; transient failures propagate for the
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ public static class ServiceCollectionExtensions
|
|||||||
/// This extension covers only the outbox-specific registrations. The
|
/// This extension covers only the outbox-specific registrations. The
|
||||||
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
||||||
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c> and
|
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c>,
|
||||||
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
|
/// <c>NotificationOptions</c> and — for configuration rows selecting the EWS transport —
|
||||||
/// call <c>AddNotificationService()</c>. Re-registering those services here would
|
/// <c>IEwsMailSender</c> with its named HTTP client — so the caller (the Host on the
|
||||||
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
|
/// central node) must also call <c>AddNotificationService()</c>. Re-registering those
|
||||||
|
/// services here would duplicate them; relying on <c>AddNotificationService</c> keeps a
|
||||||
|
/// single source of truth. Each is an optional constructor parameter on the adapter, so a
|
||||||
|
/// host that skips <c>AddNotificationService()</c> still constructs — an EWS row then
|
||||||
|
/// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
|
||||||
///
|
///
|
||||||
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
||||||
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
||||||
|
|||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user