fix(notifications): close OAuth2 SMTP + dispatcher resilience gaps (5 findings)

NS-021/NO-001: thread FromAddress into XOAUTH2 so M365 stops rejecting
sends with 535 5.7.3. Added an additive oauth2UserName parameter on
ISmtpClientWrapper.AuthenticateAsync; both NotificationService and
NotificationOutbox now pass config.FromAddress.

NO-002: clamp non-positive SmtpConfiguration.MaxRetries/RetryDelay to the
1-min / 10-attempt fallback with a Warning so a misconfigured row no
longer parks transient failures on the first attempt or burn-loops.

NO-003: route a lifecycle-scoped CancellationToken from the
NotificationOutboxActor through the dispatch sweep into the adapter so
in-flight SMTP sends abort on PostStop instead of blocking
CoordinatedShutdown for the full SMTP timeout per row.

NO-004: await the central audit writer inside the existing try/catch
instead of fire-and-forget so the audit task can't outlive the per-sweep
DI scope and writer faults reach the operator log instead of being
silently dropped.

Two AuditLog integration tests seeded RetryDelay = TimeSpan.Zero to force
immediate re-claim on the second tick; updated them to 1 ms so they keep
the same intent without tripping the NO-002 clamp.
This commit is contained in:
Joseph Doherty
2026-05-28 03:54:43 -04:00
parent e536178323
commit 291274ae76
13 changed files with 370 additions and 61 deletions
@@ -1,3 +1,6 @@
using System.Text;
using MailKit.Security;
namespace ScadaLink.NotificationService.Tests;
/// <summary>
@@ -5,6 +8,8 @@ namespace ScadaLink.NotificationService.Tests;
/// silently skip authentication for a misconfigured SMTP config — a missing
/// credential, an unrecognised auth type, or an unparseable Basic credential
/// must be a hard, surfaced error rather than an unauthenticated send.
/// NS-021: the OAuth2 (XOAUTH2) branch must carry a non-empty user identity
/// (the SMTP From address) — an empty user is rejected by M365 with `535 5.7.3`.
/// </summary>
public class MailKitSmtpClientWrapperTests
{
@@ -42,4 +47,35 @@ public class MailKitSmtpClientWrapperTests
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("basic", "nocolon"));
}
[Fact]
public async Task Authenticate_OAuth2WithoutUserName_Throws()
{
// NS-021: passing an OAuth2 access token but no user identity (FromAddress)
// used to construct `new SaslMechanismOAuth2("", credentials)`, which M365
// rejects with `535 5.7.3`. The wrapper now refuses upfront so the caller
// sees a clean configuration error rather than a confusing server reject.
var wrapper = new MailKitSmtpClientWrapper();
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: null));
await Assert.ThrowsAsync<SmtpPermanentException>(
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: ""));
}
[Fact]
public void XOAuth2InitialResponse_CarriesUserAndBearer()
{
// NS-021 regression guard: independent of the wrapper, prove that MailKit's
// SaslMechanismOAuth2 puts `user=<userName>` into the initial-response bytes
// — i.e. wiring the wrapper to pass `FromAddress` is sufficient to fix the
// M365 handshake. If MailKit ever changes the framing this test will catch it.
var sasl = new SaslMechanismOAuth2("noreply@example.com", "tok-xyz");
var initial = sasl.Challenge(string.Empty);
var asString = Encoding.UTF8.GetString(Convert.FromBase64String(initial));
Assert.Contains("user=noreply@example.com", asString);
Assert.Contains("auth=Bearer tok-xyz", asString);
}
}
@@ -115,7 +115,8 @@ public class NotificationDeliveryServiceTests
await _smtpClient.Received().ConnectAsync(
"smtp.example.com", 587, SmtpTlsMode.StartTls, Arg.Any<int>(), Arg.Any<CancellationToken>());
await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
await _smtpClient.Received().AuthenticateAsync(
"basic", "user:pass", Arg.Any<string?>(), Arg.Any<CancellationToken>());
await _smtpClient.Received().SendAsync(
"noreply@example.com",
Arg.Is<IEnumerable<string>>(bcc => bcc.Count() == 2),
@@ -370,7 +371,7 @@ public class NotificationDeliveryServiceTests
public bool Disposed { get; private set; }
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
public Task AuthenticateAsync(string authType, string? credentials, string? oauth2UserName = null, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
@@ -435,7 +436,7 @@ public class NotificationDeliveryServiceTests
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
public Task AuthenticateAsync(string authType, string? credentials, string? oauth2UserName = null, CancellationToken cancellationToken = default)
=> _failOnAuthenticate != null ? Task.FromException(_failOnAuthenticate()) : Task.CompletedTask;
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
@@ -496,7 +497,7 @@ public class NotificationDeliveryServiceTests
ConnectionTimeoutSeconds = connectionTimeoutSeconds;
return Task.CompletedTask;
}
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
public Task AuthenticateAsync(string authType, string? credentials, string? oauth2UserName = null, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
@@ -643,7 +644,7 @@ public class NotificationDeliveryServiceTests
public BlockingSmtpClient(Func<Task> onSend) => _onSend = onSend;
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
public Task AuthenticateAsync(string authType, string? credentials, string? oauth2UserName = null, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
=> _onSend();
@@ -721,17 +722,19 @@ public class NotificationDeliveryServiceTests
// ── NotificationService-012: OAuth2 delivery path coverage ──
/// <summary>An SMTP wrapper that records the auth type and credentials it received.</summary>
/// <summary>An SMTP wrapper that records the auth type, credentials, and OAuth2 user identity it received.</summary>
private sealed class RecordingAuthClient : ISmtpClientWrapper
{
public string? AuthType { get; private set; }
public string? Credentials { get; private set; }
public string? OAuth2UserName { get; private set; }
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
public Task AuthenticateAsync(string authType, string? credentials, string? oauth2UserName = null, CancellationToken cancellationToken = default)
{
AuthType = authType;
Credentials = credentials;
OAuth2UserName = oauth2UserName;
return Task.CompletedTask;
}
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
@@ -790,6 +793,9 @@ public class NotificationDeliveryServiceTests
Assert.True(result.Success);
Assert.Equal("oauth2", recording.AuthType);
Assert.Equal("oauth2-access-token-xyz", recording.Credentials);
// NS-021: OAuth2 SASL must carry the FromAddress as the user identity so
// the M365 XOAUTH2 handshake's `user=` field matches the token's mailbox.
Assert.Equal("noreply@example.com", recording.OAuth2UserName);
}
// ── NotificationService-015: unclassified exceptions must not escape SendAsync ──