Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Ews/EwsSoapMailSenderTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

343 lines
16 KiB
C#

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>
""";
/// <summary>Verifies that a success response completes the send and the expected request was sent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a non-HTTPS endpoint throws a permanent error without sending credentials.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<EwsPermanentException>(() => sender.SendAsync(request));
Assert.Equal(0, handler.InvocationCount);
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
AssertNoCredentialLeak(ex);
}
/// <summary>Verifies that a server-busy response body throws a transient error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that an unauthorized response throws a permanent error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a schema fault on an HTTP 500 response throws a permanent error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a bare service-unavailable response throws a transient error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a transport failure throws a transient error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a request exceeding the timeout throws a transient error rather than a cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that caller cancellation propagates unwrapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that a success status with an unparseable body throws a permanent error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 — those land
/// in the operational log and in the notification's stored error. The whole
/// <see cref="Exception.ToString"/> is scanned, so an inner transport exception carrying the
/// credential would fail this too.
/// </summary>
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<EwsSoapMailSender>.Instance);
/// <summary>Hands every named client the one stub handler under test.</summary>
private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
/// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
public string? RequestedClientName { get; private set; }
/// <summary>Creates an HTTP client over the fake message handler.</summary>
/// <param name="name">The logical client name.</param>
/// <returns>An HTTP client backed by the fake handler.</returns>
public HttpClient CreateClient(string name)
{
RequestedClientName = name;
return new HttpClient(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;
/// <summary>Gets the authorization scheme of the captured request.</summary>
public string? CapturedScheme { get; private set; }
/// <summary>Gets the authorization parameter of the captured request.</summary>
public string? CapturedParameter { get; private set; }
/// <summary>Gets the content media type of the captured request.</summary>
public string? CapturedMediaType { get; private set; }
/// <summary>Gets the serialized body of the captured request.</summary>
public string CapturedContent { get; private set; } = string.Empty;
/// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary>
public int InvocationCount { get; private set; }
/// <summary>Creates a handler that answers every request with the given status and XML body.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="body">The XML response body.</param>
/// <returns>A handler that returns the canned response.</returns>
public static StubHandler Responding(HttpStatusCode status, string body)
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "text/xml"),
}));
/// <summary>Creates a handler that fails every request with the given exception.</summary>
/// <param name="exception">The exception to throw.</param>
/// <returns>A handler that throws.</returns>
public static StubHandler Throwing(Exception exception)
=> new(_ => Task.FromException<HttpResponseMessage>(exception));
/// <summary>Creates a handler that waits for the given delay before answering, so a timeout can trip.</summary>
/// <param name="delay">How long to wait before responding.</param>
/// <returns>A handler that responds after the delay.</returns>
public static StubHandler Delaying(TimeSpan delay)
=> new(async token =>
{
await Task.Delay(delay, token);
return new HttpResponseMessage(HttpStatusCode.OK);
});
/// <inheritdoc />
protected override async Task<HttpResponseMessage> 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);
}
}
}