feat(notifications): no-SDK EWS SOAP mail sender with typed transient/permanent classification
This commit is contained in:
@@ -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