fix(notifications): EWS sender review follow-ups — https guard, transient-path logging, test hardening

This commit is contained in:
Joseph Doherty
2026-08-10 06:29:37 -04:00
parent 08957cc907
commit 8657fae14f
2 changed files with 63 additions and 8 deletions
@@ -76,16 +76,34 @@ public class EwsSoapMailSenderTests
public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
{
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
var sender = CreateSender(handler);
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<EwsPermanentException>(() => sender.SendAsync(request));
Assert.Equal(0, handler.InvocationCount);
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
AssertNoCredentialLeak(ex);
}
[Fact]
public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
{
@@ -189,13 +207,16 @@ public class EwsSoapMailSenderTests
}
/// <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.
/// 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)
{
Assert.DoesNotContain(Password, ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(ExpectedBase64, ex.Message, StringComparison.Ordinal);
var rendered = ex.ToString();
Assert.DoesNotContain(Password, rendered, StringComparison.Ordinal);
Assert.DoesNotContain(ExpectedBase64, rendered, StringComparison.Ordinal);
}
private static EwsSendRequest CreateRequest(int timeoutSeconds = 0)
@@ -210,12 +231,22 @@ public class EwsSoapMailSenderTests
timeoutSeconds);
private static EwsSoapMailSender CreateSender(HttpMessageHandler handler)
=> new(new FakeHttpClientFactory(handler), NullLogger<EwsSoapMailSender>.Instance);
=> 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
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
/// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
public string? RequestedClientName { get; private set; }
public HttpClient CreateClient(string name)
{
RequestedClientName = name;
return new HttpClient(handler, disposeHandler: false);
}
}
/// <summary>
@@ -237,6 +268,9 @@ public class EwsSoapMailSenderTests
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; }
public static StubHandler Responding(HttpStatusCode status, string body)
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
{
@@ -256,6 +290,7 @@ public class EwsSoapMailSenderTests
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;