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 factory = new FakeHttpClientFactory(handler); var sender = CreateSender(factory); await sender.SendAsync(CreateRequest()); Assert.Equal(EwsSoapMailSender.HttpClientName, factory.RequestedClientName); 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_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials() { // A Basic header is a replayable cleartext credential: the guard must fire before the // request is ever built, so a misconfigured http:// URL cannot put it on the wire. var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody); var sender = CreateSender(handler); var request = CreateRequest() with { Endpoint = new Uri("http://ews.example.test/EWS/Exchange.asmx") }; var ex = await Assert.ThrowsAsync(() => sender.SendAsync(request)); Assert.Equal(0, handler.InvocationCount); Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase); AssertNoCredentialLeak(ex); } [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 — those land /// in the operational log and in the notification's stored error. The whole /// is scanned, so an inner transport exception carrying the /// credential would fail this too. /// private static void AssertNoCredentialLeak(Exception ex) { var rendered = ex.ToString(); Assert.DoesNotContain(Password, rendered, StringComparison.Ordinal); Assert.DoesNotContain(ExpectedBase64, rendered, 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) => CreateSender(new FakeHttpClientFactory(handler)); private static EwsSoapMailSender CreateSender(FakeHttpClientFactory factory) => new(factory, NullLogger.Instance); /// Hands every named client the one stub handler under test. private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { /// The client name the sender asked for, so tests can pin the named registration. public string? RequestedClientName { get; private set; } public HttpClient CreateClient(string name) { RequestedClientName = name; return new HttpClient(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; /// How many times the handler was reached — 0 proves a guard fired first. public int InvocationCount { get; private set; } 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) { InvocationCount++; 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); } } }