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:
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user