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;
///
/// 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.
///
/// Every credential here is fake and every endpoint points at the reserved .test TLD —
/// no test in this file touches a network.
///
///
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");
/// The Basic-auth value the sender is expected to produce for the fake credential.
private static readonly string ExpectedBase64 =
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"));
private const string SuccessBody = """
NoError
""";
private const string ServerBusyBody = """
The server is busy.
ErrorServerBusy
""";
private const string SchemaValidationFaultBody = """
a:ErrorSchemaValidation
The request failed schema validation.
ErrorSchemaValidation
""";
[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(() => 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(() => 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(() => sender.SendAsync(CreateRequest()));
Assert.Contains("ErrorSchemaValidation", ex.Message);
AssertNoCredentialLeak(ex);
}
[Fact]
public async Task SendAsync_BareServiceUnavailable_ThrowsTransient()
{
var handler = StubHandler.Responding(HttpStatusCode.ServiceUnavailable, "proxy down");
var sender = CreateSender(handler);
var ex = await Assert.ThrowsAsync(() => 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(() => sender.SendAsync(CreateRequest()));
Assert.IsType(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(
() => 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(
() => sender.SendAsync(CreateRequest(timeoutSeconds: 30), cts.Token));
Assert.IsNotType(ex);
Assert.IsNotType(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(() => sender.SendAsync(CreateRequest()));
AssertNoCredentialLeak(ex);
}
///
/// 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.
///
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.Instance);
/// Hands every named client the one stub handler under test.
private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
///
/// 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.
///
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func> _responder;
private StubHandler(Func> 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(exception));
public static StubHandler Delaying(TimeSpan delay)
=> new(async token =>
{
await Task.Delay(delay, token);
return new HttpResponseMessage(HttpStatusCode.OK);
});
protected override async Task 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);
}
}
}