291274ae76
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.
82 lines
3.5 KiB
C#
82 lines
3.5 KiB
C#
using System.Text;
|
|
using MailKit.Security;
|
|
|
|
namespace ScadaLink.NotificationService.Tests;
|
|
|
|
/// <summary>
|
|
/// NS-016: <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never
|
|
/// 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
|
|
{
|
|
[Fact]
|
|
public async Task Authenticate_EmptyCredentials_Throws()
|
|
{
|
|
// An AuthType of "basic"/"oauth2" with a null/empty Credentials value is a
|
|
// misconfigured row; the wrapper used to "return" and send unauthenticated.
|
|
var wrapper = new MailKitSmtpClientWrapper();
|
|
|
|
await Assert.ThrowsAsync<SmtpPermanentException>(
|
|
() => wrapper.AuthenticateAsync("basic", null));
|
|
await Assert.ThrowsAsync<SmtpPermanentException>(
|
|
() => wrapper.AuthenticateAsync("oauth2", ""));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Authenticate_UnknownAuthType_Throws()
|
|
{
|
|
// The switch had cases only for "basic"/"oauth2" and no default — any other
|
|
// value (typo, future "ntlm") fell through and sent unauthenticated.
|
|
var wrapper = new MailKitSmtpClientWrapper();
|
|
|
|
await Assert.ThrowsAsync<SmtpPermanentException>(
|
|
() => wrapper.AuthenticateAsync("ntlm", "user:pass"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Authenticate_BasicCredentialWithoutColon_Throws()
|
|
{
|
|
// A "basic" credential string that does not split into exactly two parts was
|
|
// silently skipped — the connection then sent unauthenticated.
|
|
var wrapper = new MailKitSmtpClientWrapper();
|
|
|
|
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);
|
|
}
|
|
}
|