feat(notifications): no-SDK EWS SOAP mail sender with typed transient/permanent classification
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Sends notification mail through Exchange Web Services over plain HTTPS + SOAP — no EWS SDK,
|
||||
/// honouring the project's no-new-NuGet-package rule.
|
||||
/// <para>
|
||||
/// Authentication is an explicit <c>Authorization: Basic</c> header set on each
|
||||
/// <see cref="HttpRequestMessage"/>, never on the shared <see cref="HttpClient"/>: the factory's
|
||||
/// handler is pooled and shared across every configuration, so a client-level credential would
|
||||
/// leak between callers. Negotiate/NTLM is the documented follow-on if Basic is ever disabled on
|
||||
/// the EWS virtual directory.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Classification (see the design's §4.3) prefers what Exchange <em>said</em> over what HTTP
|
||||
/// reported: a parsed response code decides the outcome even on a 500, and only an unparseable
|
||||
/// body falls back to the status code. Unclassified failures default to permanent, matching the
|
||||
/// SMTP adapter's stance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The password and the base64 Basic-auth value never appear in a thrown message or a log line:
|
||||
/// attempts are logged at Debug with the endpoint host and a recipient <em>count</em> only, and
|
||||
/// every surfaced message runs through <see cref="CredentialRedactor"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EwsSoapMailSender : IEwsMailSender
|
||||
{
|
||||
/// <summary>The named <see cref="HttpClient"/> registered for EWS in <c>ServiceCollectionExtensions</c>.</summary>
|
||||
public const string HttpClientName = "EwsMail";
|
||||
|
||||
/// <summary>Mask applied to the base64 credential, matching <see cref="CredentialRedactor"/>'s.</summary>
|
||||
private const string Mask = "***REDACTED***";
|
||||
|
||||
/// <summary>
|
||||
/// EWS response codes that describe load or availability rather than a defect in the request.
|
||||
/// Everything else — schema faults, recipient rejections, authorization — is permanent.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> TransientResponseCodes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ErrorServerBusy",
|
||||
"ErrorInternalServerTransientError",
|
||||
"ErrorTimeoutExpired",
|
||||
"ErrorMailboxStoreUnavailable",
|
||||
"ErrorInsufficientResources",
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<EwsSoapMailSender> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="EwsSoapMailSender"/>.</summary>
|
||||
/// <param name="httpClientFactory">Factory creating the named <c>"EwsMail"</c> HTTP client per send.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public EwsSoapMailSender(IHttpClientFactory httpClientFactory, ILogger<EwsSoapMailSender> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClientFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var envelope = EwsSoapEnvelope.BuildCreateItem(
|
||||
request.FromAddress, request.BccRecipients, request.Subject, request.Body);
|
||||
|
||||
var packedCredential = $"{request.Username}:{request.Password}";
|
||||
var base64Credential = Convert.ToBase64String(Encoding.UTF8.GetBytes(packedCredential));
|
||||
|
||||
// Recipient COUNT only — addresses are notification content and do not belong in the log.
|
||||
_logger.LogDebug(
|
||||
"Submitting EWS CreateItem to {EwsHost} for {RecipientCount} recipient(s).",
|
||||
request.Endpoint.Host,
|
||||
request.BccRecipients.Count);
|
||||
|
||||
// Per-request timeout layered over the caller's token, so a stalled Exchange surfaces as
|
||||
// a TaskCanceledException with no caller cancel — classified transient below — while a
|
||||
// genuine caller cancel still propagates unwrapped. HttpClient.Timeout is left alone: the
|
||||
// client is shared and its timeout is not per-configuration.
|
||||
using var linkedCts = request.TimeoutSeconds > 0
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: null;
|
||||
linkedCts?.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds));
|
||||
var sendToken = linkedCts?.Token ?? cancellationToken;
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
try
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, request.Endpoint)
|
||||
{
|
||||
Content = new StringContent(envelope, Encoding.UTF8, "text/xml"),
|
||||
};
|
||||
httpRequest.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", base64Credential);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, sendToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(sendToken);
|
||||
|
||||
ThrowOnFailure(response.StatusCode, responseBody, packedCredential, base64Credential);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller cancelled: neither a success nor a delivery failure, so it propagates
|
||||
// unchanged rather than being recorded as a transient send error.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
// Transport failure or our own timeout — availability-shaped, so retry.
|
||||
throw new EwsTransientException(
|
||||
Scrub(
|
||||
$"EWS request to {request.Endpoint.Host} failed: {ex.Message}",
|
||||
packedCredential,
|
||||
base64Credential),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies one EWS response, returning quietly on success and throwing the matching typed
|
||||
/// exception otherwise. The parsed response body wins over the HTTP status whenever it is
|
||||
/// intelligible; only an unparseable body falls back to the status code.
|
||||
/// </summary>
|
||||
private void ThrowOnFailure(
|
||||
HttpStatusCode statusCode,
|
||||
string responseBody,
|
||||
string packedCredential,
|
||||
string base64Credential)
|
||||
{
|
||||
var parsed = EwsResponseParser.Parse(responseBody);
|
||||
|
||||
// Exchange accepted the message. A non-2xx status alongside a Success response class is
|
||||
// not a thing Exchange does, but if a proxy rewrites the status the message still went.
|
||||
if (parsed.Kind == EwsResponseKind.Success
|
||||
|| string.Equals(parsed.ResponseCode, "NoError", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var status = (int)statusCode;
|
||||
|
||||
if (parsed.Kind is EwsResponseKind.Error or EwsResponseKind.Fault)
|
||||
{
|
||||
var code = parsed.ResponseCode ?? "(no response code)";
|
||||
var detail = Scrub(
|
||||
$"EWS returned {code} (HTTP {status}): {parsed.MessageText ?? "(no message text)"}",
|
||||
packedCredential,
|
||||
base64Credential);
|
||||
|
||||
if (parsed.ResponseCode is not null && TransientResponseCodes.Contains(parsed.ResponseCode))
|
||||
{
|
||||
_logger.LogWarning("Transient EWS failure: {Detail}", detail);
|
||||
throw new EwsTransientException(detail);
|
||||
}
|
||||
|
||||
// Includes a fault carrying no response code: the default-permanent stance keeps an
|
||||
// unrecognised failure from retrying forever.
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", detail);
|
||||
throw new EwsPermanentException(detail);
|
||||
}
|
||||
|
||||
// Unparseable body — nothing but the HTTP status is left to judge on.
|
||||
var message = $"EWS endpoint returned {status} ({statusCode}) with an unrecognised body";
|
||||
|
||||
// 401/403 credential or authorization, 404/410 wrong URL, 405 wrong verb/endpoint: all
|
||||
// configuration defects, and retrying 401 burns a domain account's lockout budget.
|
||||
if (status is 401 or 403 or 404 or 405 or 410)
|
||||
{
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", message);
|
||||
throw new EwsPermanentException(message);
|
||||
}
|
||||
|
||||
if (status is 408 or 429 || status >= 500)
|
||||
{
|
||||
_logger.LogWarning("Transient EWS failure: {Detail}", message);
|
||||
throw new EwsTransientException(message);
|
||||
}
|
||||
|
||||
// A 2xx whose body is not an EWS response is a protocol violation from something in the
|
||||
// path (proxy error page, captive portal); anything else non-success is unclassified.
|
||||
// Neither is fixed by replaying the same request.
|
||||
_logger.LogError("Permanent EWS failure: {Detail}", message);
|
||||
throw new EwsPermanentException(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks both credential shapes — the packed <c>user:password</c> (via the shared
|
||||
/// <see cref="CredentialRedactor"/>, which also covers the bare password) and the base64
|
||||
/// Basic-auth value — out of text bound for an exception message or a log line.
|
||||
/// </summary>
|
||||
private static string Scrub(string text, string packedCredential, string base64Credential)
|
||||
=> CredentialRedactor
|
||||
.Scrub(text, packedCredential)
|
||||
.Replace(base64Credential, Mask, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot EWS mail submission (<c>CreateItem</c>, <c>SendOnly</c>, BCC-only).
|
||||
/// <para>
|
||||
/// The seam the central Notification Outbox's <c>EmailNotificationDeliveryAdapter</c> calls when a
|
||||
/// <c>SmtpConfiguration</c> selects the EWS transport, mirroring the role
|
||||
/// <c>ISmtpClientWrapper</c> plays for the SMTP transport.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IEwsMailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends one message through Exchange Web Services.
|
||||
/// </summary>
|
||||
/// <param name="request">The endpoint, credential, sender, recipients and content to submit.</param>
|
||||
/// <param name="cancellationToken">Token cancelling the send; propagates unwrapped when the caller cancels.</param>
|
||||
/// <returns>A task completing when Exchange has accepted the message.</returns>
|
||||
/// <exception cref="EwsPermanentException">
|
||||
/// The send failed for a reason retrying cannot fix — authentication (401/403), a wrong URL
|
||||
/// (404), a schema fault, or a recipient rejection.
|
||||
/// </exception>
|
||||
/// <exception cref="EwsTransientException">
|
||||
/// The send failed for an availability-shaped reason — network/DNS/timeout, HTTP 5xx/408/429,
|
||||
/// or an <c>ErrorServerBusy</c>-class response code — and is worth retrying.
|
||||
/// </exception>
|
||||
Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything one EWS submission needs. Carried per call rather than held on the sender so the
|
||||
/// sender stays a stateless singleton and the credential never outlives the request.
|
||||
/// </summary>
|
||||
/// <param name="Endpoint">The absolute HTTPS EWS URL (for example <c>https://host/EWS/Exchange.asmx</c>).</param>
|
||||
/// <param name="Username">The service account, which may be domain-qualified (<c>domain\user</c>).</param>
|
||||
/// <param name="Password">The service account password.</param>
|
||||
/// <param name="FromAddress">The sending mailbox address.</param>
|
||||
/// <param name="BccRecipients">The recipients; all addressed BCC so they cannot see one another.</param>
|
||||
/// <param name="Subject">The message subject.</param>
|
||||
/// <param name="Body">The plain-text message body.</param>
|
||||
/// <param name="TimeoutSeconds">
|
||||
/// The per-request timeout; a non-positive value leaves the <see cref="HttpClient"/> default in force.
|
||||
/// </param>
|
||||
public sealed record EwsSendRequest(
|
||||
Uri Endpoint,
|
||||
string Username,
|
||||
string Password,
|
||||
string FromAddress,
|
||||
IReadOnlyList<string> BccRecipients,
|
||||
string Subject,
|
||||
string Body,
|
||||
int TimeoutSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Signals an availability-shaped EWS failure that is worth retrying.
|
||||
/// </summary>
|
||||
public class EwsTransientException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the exception with a message and optional inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">Message describing the transient EWS failure; never carries the credential.</param>
|
||||
/// <param name="innerException">Optional underlying transport exception.</param>
|
||||
public EwsTransientException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals an EWS failure that retrying cannot fix, so the notification parks immediately.
|
||||
/// </summary>
|
||||
public class EwsPermanentException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the exception with a message and optional inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">Message describing the permanent EWS failure; never carries the credential.</param>
|
||||
/// <param name="innerException">Optional underlying transport exception.</param>
|
||||
public EwsPermanentException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the shared SMTP delivery primitives consumed by the central Notification
|
||||
/// Registers the shared email delivery primitives consumed by the central Notification
|
||||
/// Outbox's <c>EmailNotificationDeliveryAdapter</c>: <see cref="NotificationOptions"/>,
|
||||
/// <see cref="OAuth2TokenService"/>, and the <see cref="ISmtpClientWrapper"/> factory.
|
||||
/// <see cref="OAuth2TokenService"/>, the <see cref="ISmtpClientWrapper"/> factory, and —
|
||||
/// for the EWS transport — <see cref="IEwsMailSender"/> with its named HTTP client.
|
||||
/// Central-only — sites no longer deliver notifications (see
|
||||
/// <c>Component-NotificationService.md</c>), and the orphaned site-shaped
|
||||
/// <c>NotificationDeliveryService</c> + <c>INotificationDeliveryService</c> contract
|
||||
@@ -30,6 +32,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<OAuth2TokenService>();
|
||||
services.AddSingleton<Func<ISmtpClientWrapper>>(_ => () => new MailKitSmtpClientWrapper());
|
||||
|
||||
// EWS transport: a named client so the EWS calls get their own handler pool, and a
|
||||
// stateless singleton sender (credentials travel per request, never on the client).
|
||||
services.AddHttpClient(EwsSoapMailSender.HttpClientName);
|
||||
services.TryAddSingleton<IEwsMailSender, EwsSoapMailSender>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the EWS SOAP mail sender: request shape (Basic header, content type, BCC-only
|
||||
/// envelope) and the transient-versus-permanent classification of every failure shape.
|
||||
/// <para>
|
||||
/// Every credential here is fake and every endpoint points at the reserved <c>.test</c> TLD —
|
||||
/// no test in this file touches a network.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EwsSoapMailSenderTests
|
||||
{
|
||||
private const string Username = @"dom\svc";
|
||||
private const string Password = "not-a-real-password-1234";
|
||||
private const string Recipient = "operator@example.test";
|
||||
private static readonly Uri Endpoint = new("https://ews.example.test/EWS/Exchange.asmx");
|
||||
|
||||
/// <summary>The Basic-auth value the sender is expected to produce for the fake credential.</summary>
|
||||
private static readonly string ExpectedBase64 =
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"));
|
||||
|
||||
private const string SuccessBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Success">
|
||||
<m:ResponseCode>NoError</m:ResponseCode>
|
||||
<m:Items><t:Message><t:ItemId Id="AAA=" ChangeKey="CQ=="/></t:Message></m:Items>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string ServerBusyBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
||||
<m:ResponseMessages>
|
||||
<m:CreateItemResponseMessage ResponseClass="Error">
|
||||
<m:MessageText>The server is busy.</m:MessageText>
|
||||
<m:ResponseCode>ErrorServerBusy</m:ResponseCode>
|
||||
</m:CreateItemResponseMessage>
|
||||
</m:ResponseMessages>
|
||||
</m:CreateItemResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
private const string SchemaValidationFaultBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode xmlns:a="http://schemas.microsoft.com/exchange/services/2006/types">a:ErrorSchemaValidation</faultcode>
|
||||
<faultstring xml:lang="en-US">The request failed schema validation.</faultstring>
|
||||
<detail>
|
||||
<e:ResponseCode xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">ErrorSchemaValidation</e:ResponseCode>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
await sender.SendAsync(CreateRequest());
|
||||
|
||||
Assert.Equal("Basic", handler.CapturedScheme);
|
||||
Assert.Equal(ExpectedBase64, handler.CapturedParameter);
|
||||
Assert.Equal("text/xml", handler.CapturedMediaType);
|
||||
Assert.Contains(Recipient, handler.CapturedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, ServerBusyBody);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("ErrorServerBusy", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Unauthorized_ThrowsPermanent()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.Unauthorized, string.Empty);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("401", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent()
|
||||
{
|
||||
// The parsed fault beats the HTTP status: a 500 would otherwise look transient, but
|
||||
// a schema-invalid request is never fixed by retrying it unchanged.
|
||||
var handler = StubHandler.Responding(HttpStatusCode.InternalServerError, SchemaValidationFaultBody);
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("ErrorSchemaValidation", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_BareServiceUnavailable_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Responding(HttpStatusCode.ServiceUnavailable, "<html>proxy down</html>");
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.Contains("503", ex.Message);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_TransportFailure_ThrowsTransient()
|
||||
{
|
||||
var handler = StubHandler.Throwing(new HttpRequestException("No such host is known."));
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
Assert.IsType<HttpRequestException>(ex.InnerException);
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation()
|
||||
{
|
||||
var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsTransientException>(
|
||||
() => sender.SendAsync(CreateRequest(timeoutSeconds: 1)));
|
||||
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped()
|
||||
{
|
||||
var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
|
||||
var sender = CreateSender(handler);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => sender.SendAsync(CreateRequest(timeoutSeconds: 30), cts.Token));
|
||||
|
||||
Assert.IsNotType<EwsTransientException>(ex);
|
||||
Assert.IsNotType<EwsPermanentException>(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent()
|
||||
{
|
||||
// A 200 whose body is not an EWS response is a protocol violation from something in
|
||||
// the path (captive portal / proxy error page); replaying it changes nothing.
|
||||
var handler = StubHandler.Responding(HttpStatusCode.OK, "not xml at all");
|
||||
var sender = CreateSender(handler);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));
|
||||
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Neither the password nor the base64 Basic-auth value may reach an exception message —
|
||||
/// those messages land in the operational log and in the notification's stored error.
|
||||
/// </summary>
|
||||
private static void AssertNoCredentialLeak(Exception ex)
|
||||
{
|
||||
Assert.DoesNotContain(Password, ex.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(ExpectedBase64, ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EwsSendRequest CreateRequest(int timeoutSeconds = 0)
|
||||
=> new(
|
||||
Endpoint,
|
||||
Username,
|
||||
Password,
|
||||
"alerts@example.test",
|
||||
new[] { Recipient },
|
||||
"Alarm raised",
|
||||
"Tank 4 level high.",
|
||||
timeoutSeconds);
|
||||
|
||||
private static EwsSoapMailSender CreateSender(HttpMessageHandler handler)
|
||||
=> new(new FakeHttpClientFactory(handler), NullLogger<EwsSoapMailSender>.Instance);
|
||||
|
||||
/// <summary>Hands every named client the one stub handler under test.</summary>
|
||||
private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the outgoing request (auth header, content type, serialized body) and answers
|
||||
/// with a canned response, a thrown exception, or a delay long enough to trip a timeout.
|
||||
/// </summary>
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<HttpResponseMessage>> _responder;
|
||||
|
||||
private StubHandler(Func<CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
=> _responder = responder;
|
||||
|
||||
public string? CapturedScheme { get; private set; }
|
||||
|
||||
public string? CapturedParameter { get; private set; }
|
||||
|
||||
public string? CapturedMediaType { get; private set; }
|
||||
|
||||
public string CapturedContent { get; private set; } = string.Empty;
|
||||
|
||||
public static StubHandler Responding(HttpStatusCode status, string body)
|
||||
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/xml"),
|
||||
}));
|
||||
|
||||
public static StubHandler Throwing(Exception exception)
|
||||
=> new(_ => Task.FromException<HttpResponseMessage>(exception));
|
||||
|
||||
public static StubHandler Delaying(TimeSpan delay)
|
||||
=> new(async token =>
|
||||
{
|
||||
await Task.Delay(delay, token);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CapturedScheme = request.Headers.Authorization?.Scheme;
|
||||
CapturedParameter = request.Headers.Authorization?.Parameter;
|
||||
CapturedMediaType = request.Content?.Headers.ContentType?.MediaType;
|
||||
CapturedContent = request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
return await _responder(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user