using System.Net; using System.Text.Json; using MailKit; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.StoreAndForward; namespace ScadaLink.NotificationService.Tests; /// /// WP-11/12: Tests for notification delivery — SMTP delivery, error classification, S&F integration. /// public class NotificationDeliveryServiceTests { private readonly INotificationRepository _repository = Substitute.For(); private readonly ISmtpClientWrapper _smtpClient = Substitute.For(); private NotificationDeliveryService CreateService(StoreAndForward.StoreAndForwardService? sf = null) { return new NotificationDeliveryService( _repository, () => _smtpClient, NullLogger.Instance, tokenService: null, storeAndForward: sf); } private void SetupHappyPath() { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }, new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 } }; var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { smtpConfig }); } [Fact] public async Task Send_ListNotFound_ReturnsError() { _repository.GetListByNameAsync("nonexistent").Returns((NotificationList?)null); var service = CreateService(); var result = await service.SendAsync("nonexistent", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("not found", result.ErrorMessage); } [Fact] public async Task Send_NoRecipients_ReturnsError() { var list = new NotificationList("empty-list") { Id = 1 }; _repository.GetListByNameAsync("empty-list").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(new List()); var service = CreateService(); var result = await service.SendAsync("empty-list", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("no recipients", result.ErrorMessage); } [Fact] public async Task Send_NoSmtpConfig_ReturnsError() { var list = new NotificationList("test") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; _repository.GetListByNameAsync("test").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List()); var service = CreateService(); var result = await service.SendAsync("test", "Subject", "Body"); Assert.False(result.Success); Assert.Contains("No SMTP configuration", result.ErrorMessage); } [Fact] public async Task Send_Successful_ReturnsSuccess() { SetupHappyPath(); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Something happened"); Assert.True(result.Success); Assert.Null(result.ErrorMessage); Assert.False(result.WasBuffered); } [Fact] public async Task Send_SmtpConnectsWithCorrectParams() { SetupHappyPath(); var service = CreateService(); await service.SendAsync("ops-team", "Alert", "Body"); await _smtpClient.Received().ConnectAsync( "smtp.example.com", 587, SmtpTlsMode.StartTls, Arg.Any(), Arg.Any()); await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any()); await _smtpClient.Received().SendAsync( "noreply@example.com", Arg.Is>(bcc => bcc.Count() == 2), "Alert", "Body", Arg.Any()); } [Fact] public async Task Send_PermanentSmtpError_ReturnsErrorDirectly() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpPermanentException("550 Mailbox not found")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("Permanent SMTP error", result.ErrorMessage); } [Fact] public async Task Send_TransientError_NoStoreAndForward_ReturnsError() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TimeoutException("Connection timed out")); var service = CreateService(sf: null); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("store-and-forward not available", result.ErrorMessage); } [Fact] public async Task Send_UsesBccDelivery_AllRecipientsInBcc() { SetupHappyPath(); IEnumerable? capturedBcc = null; _smtpClient.SendAsync( Arg.Any(), Arg.Do>(bcc => capturedBcc = bcc), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); var service = CreateService(); await service.SendAsync("ops-team", "Alert", "Body"); Assert.NotNull(capturedBcc); var bccList = capturedBcc!.ToList(); Assert.Equal(2, bccList.Count); Assert.Contains("alice@example.com", bccList); Assert.Contains("bob@example.com", bccList); } [Fact] public async Task Send_TransientError_WithStoreAndForward_BuffersMessage() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TimeoutException("Connection timed out")); var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared"; var storage = new StoreAndForward.StoreAndForwardStorage( $"Data Source={dbName}", NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForward.StoreAndForwardOptions(); var sfService = new StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger.Instance); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } // ── NotificationService-001: buffered-notification delivery handler ── private static StoreAndForward.StoreAndForwardMessage BufferedNotification(string listName) => new() { Id = Guid.NewGuid().ToString("N"), Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification, Target = listName, PayloadJson = $$"""{"ListName":"{{listName}}","Subject":"Alert","Message":"Body"}""", }; [Fact] public async Task DeliverBuffered_HappyPath_ReturnsTrue() { SetupHappyPath(); var service = CreateService(); var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team")); Assert.True(delivered); } [Fact] public async Task DeliverBuffered_ListNoLongerExists_ReturnsFalseSoMessageParks() { _repository.GetListByNameAsync("gone-list").Returns((NotificationList?)null); var service = CreateService(); var delivered = await service.DeliverBufferedAsync(BufferedNotification("gone-list")); Assert.False(delivered); // permanent — the S&F engine parks the message } // ── NotificationService-002: cancellation must not be misclassified as transient ── /// /// Like but matches any , /// so tests that pass an already-cancelled token still resolve the list/recipients. /// private void SetupHappyPathAnyToken() { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team", Arg.Any()).Returns(list); _repository.GetRecipientsByListIdAsync(1, Arg.Any()).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync(Arg.Any()) .Returns(new List { smtpConfig }); } [Fact] public async Task Send_CancellationRequested_PropagatesAndDoesNotBuffer() { SetupHappyPathAnyToken(); using var cts = new CancellationTokenSource(); cts.Cancel(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new OperationCanceledException(cts.Token)); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); await Assert.ThrowsAnyAsync( () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token)); // The cancellation propagated instead of being buffered for retry. var depth = await sfService.GetBufferDepthAsync(); depth.TryGetValue(ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification, out var count); Assert.Equal(0, count); } [Fact] public async Task Send_TaskCanceledException_WithCancellation_Propagates() { SetupHappyPathAnyToken(); using var cts = new CancellationTokenSource(); cts.Cancel(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new TaskCanceledException()); var service = CreateService(sf: null); await Assert.ThrowsAnyAsync( () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token)); } // ── NotificationService-003: classify on MailKit typed exceptions / status codes ── [Fact] public async Task Send_Smtp5xxCommandException_ClassifiedPermanent() { SetupHappyPath(); // 550 MailboxUnavailable — a real permanent rejection. _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpCommandException( SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("Permanent SMTP error", result.ErrorMessage); } [Fact] public async Task Send_Smtp4xxCommandException_ClassifiedTransientAndBuffered() { SetupHappyPath(); // 450 MailboxBusy — a real transient failure. _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpCommandException( SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try again")); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } [Fact] public async Task Send_NonSmtpExceptionWith5xxLookalikeText_NotClassifiedAsPermanentSmtpError() { // NS-003: the old classifier promoted ANY exception whose message contained // "5." / "550" / etc. to a permanent SMTP error — so an unrelated failure // referencing a host like "smtp5.example.com" was silently swallowed as a // clean "Permanent SMTP error" result. Classification now uses MailKit's // typed exceptions only, so a non-SMTP exception is no longer misclassified. // NS-015: that unclassified exception no longer escapes SendAsync either — it // returns a clean generic "delivery failed" result (NOT "Permanent SMTP error"). SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("internal error talking to smtp5.example.com")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); // It is reported as a generic delivery failure, not mistaken for a 5xx rejection. Assert.DoesNotContain("Permanent SMTP error", result.ErrorMessage); } [Fact] public async Task Send_SmtpProtocolException_ClassifiedTransient() { SetupHappyPath(); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpProtocolException("protocol error")); var sfService = await CreateSfServiceAsync(); var service = CreateService(sf: sfService); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.True(result.WasBuffered); } // ── NotificationService-004: DeliverAsync must create exactly one client and dispose it ── private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable { 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) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public void Dispose() => Disposed = true; } [Fact] public async Task Send_CreatesExactlyOneSmtpClient_AndDisposesIt() { SetupHappyPath(); var created = new List(); var service = new NotificationDeliveryService( _repository, () => { var c = new TrackingSmtpClient(); created.Add(c); return c; }, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Single(created); // NS-004: factory invoked once, not twice Assert.True(created[0].Disposed); // the client actually used is disposed } private static async Task CreateSfServiceAsync() { var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared"; var storage = new StoreAndForwardStorage( $"Data Source={dbName}", NullLogger.Instance); await storage.InitializeAsync(); return new StoreAndForwardService( storage, new StoreAndForwardOptions(), NullLogger.Instance); } // ── NotificationService-010: SMTP client is disconnected on the failure path ── /// /// An SMTP wrapper that records whether ran and /// can be told to fail at a chosen stage of the delivery sequence. /// private sealed class DisconnectTrackingClient : ISmtpClientWrapper, IDisposable { private readonly Func? _failOnSend; private readonly Func? _failOnAuthenticate; public DisconnectTrackingClient( Func? failOnSend = null, Func? failOnAuthenticate = null) { _failOnSend = failOnSend; _failOnAuthenticate = failOnAuthenticate; } public bool Disconnected { get; private set; } 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) => _failOnAuthenticate != null ? Task.FromException(_failOnAuthenticate()) : Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => _failOnSend != null ? Task.FromException(_failOnSend()) : Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) { Disconnected = true; return Task.CompletedTask; } public void Dispose() => Disposed = true; } [Fact] public async Task Send_TransientFailureDuringSend_StillDisconnectsClient() { // NS-010: DisconnectAsync used to run only on the success path inside the // try block. A failure in SendAsync left the authenticated connection open // (the SMTP QUIT was never issued), leaking server connection slots under // sustained transient failures. SetupHappyPath(); var tracking = new DisconnectTrackingClient( failOnSend: () => new SmtpProtocolException("protocol error")); var service = new NotificationDeliveryService( _repository, () => tracking, NullLogger.Instance); await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when the send fails"); } [Fact] public async Task Send_FailureDuringAuthenticate_StillDisconnectsClient() { // NS-010: an AuthenticateAsync failure must also tear the connection down. SetupHappyPath(); var tracking = new DisconnectTrackingClient( failOnAuthenticate: () => new SmtpProtocolException("auth handshake failed")); var service = new NotificationDeliveryService( _repository, () => tracking, NullLogger.Instance); await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when authentication fails"); } // ── NotificationService-005: explicit TLS mode passed through to the wrapper ── /// An SMTP wrapper that records the TLS mode and timeout it was connected with. private sealed class RecordingTlsClient : ISmtpClientWrapper { public SmtpTlsMode? TlsMode { get; private set; } public int ConnectionTimeoutSeconds { get; private set; } public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default) { TlsMode = tlsMode; ConnectionTimeoutSeconds = connectionTimeoutSeconds; return Task.CompletedTask; } public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } private void SetupHappyPathWithSmtp(SmtpConfiguration smtpConfig) { var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 } }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { smtpConfig }); } [Fact] public async Task Send_TlsModeNone_DoesNotNegotiateTls() { // NS-005: TlsMode "none" must connect with SmtpTlsMode.None, not the old // SecureSocketOptions.Auto (which let MailKit opportunistically negotiate TLS). var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 25, Credentials = "user:pass", TlsMode = "none" }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Equal(SmtpTlsMode.None, recording.TlsMode); } [Fact] public async Task Send_TlsModeSsl_UsesImplicitSsl() { // NS-005: TlsMode "ssl" (port 465 implicit TLS) must be honoured, not // collapsed into the same path as "none". var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 465, Credentials = "user:pass", TlsMode = "ssl" }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Equal(SmtpTlsMode.Ssl, recording.TlsMode); } [Fact] public async Task Send_UnknownTlsMode_ReturnsErrorNotSilentFallback() { var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "bogus" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingTlsClient(), NullLogger.Instance); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("TLS mode", result.ErrorMessage); } // ── NotificationService-007: connection timeout passed through to the wrapper ── [Fact] public async Task Send_PassesConfiguredConnectionTimeoutToClient() { // NS-007: SmtpConfiguration.ConnectionTimeoutSeconds must reach the wrapper // so SmtpClient.Timeout is set; it was previously dead configuration. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", ConnectionTimeoutSeconds = 17 }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance); await service.SendAsync("ops-team", "Alert", "Body"); Assert.Equal(17, recording.ConnectionTimeoutSeconds); } [Fact] public async Task Send_MaxConcurrentConnections_LimitsConcurrentDeliveries() { // NS-007: MaxConcurrentConnections must throttle concurrent SMTP deliveries. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", MaxConcurrentConnections = 2 }; SetupHappyPathWithSmtp(cfg); var inFlight = 0; var maxObserved = 0; var gate = new SemaphoreSlim(0); var sync = new object(); var service = new NotificationDeliveryService( _repository, () => new BlockingSmtpClient( onSend: async () => { lock (sync) { inFlight++; if (inFlight > maxObserved) maxObserved = inFlight; } await gate.WaitAsync(); lock (sync) { inFlight--; } }), NullLogger.Instance); var sends = Enumerable.Range(0, 6) .Select(_ => service.SendAsync("ops-team", "Alert", "Body")) .ToList(); // Give the throttled sends time to reach the SMTP send call. await Task.Delay(200); gate.Release(6); await Task.WhenAll(sends); Assert.True(maxObserved <= 2, $"Expected at most 2 concurrent deliveries, observed {maxObserved}"); } private sealed class BlockingSmtpClient : ISmtpClientWrapper, IDisposable { private readonly Func _onSend; public BlockingSmtpClient(Func 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) => Task.CompletedTask; public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => _onSend(); public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public void Dispose() { } } // ── NotificationService-008: recipient address validation ── [Fact] public async Task Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow() { // NS-008: a malformed recipient address previously caused MailboxAddress.Parse // to throw ParseException, which escaped SendAsync unhandled. It must now // produce a clean NotificationResult failure. var list = new NotificationList("ops-team") { Id = 1 }; var recipients = new List { new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }, new("Bad", "not a valid address @@") { Id = 2, NotificationListId = 1 } }; var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; _repository.GetListByNameAsync("ops-team").Returns(list); _repository.GetRecipientsByListIdAsync(1).Returns(recipients); _repository.GetAllSmtpConfigurationsAsync().Returns(new List { cfg }); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("not a valid address @@", result.ErrorMessage); } // ── NotificationService-009: credential secrets scrubbed from logs/results ── [Fact] public async Task Send_PermanentError_RedactsCredentialFromResultMessage() { // NS-009: a permanent-failure message echoing a credential fragment must be // scrubbed before it reaches the caller-facing NotificationResult. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new SmtpPermanentException("550 rejected — password Hunter2Secret is invalid")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage); } [Fact] public async Task Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow() { var cfg = new SmtpConfiguration("smtp.example.com", "basic", "@@bad-from@@") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); } // ── NotificationService-012: OAuth2 delivery path coverage ── /// An SMTP wrapper that records the auth type and credentials it received. private sealed class RecordingAuthClient : ISmtpClientWrapper { public string? AuthType { get; private set; } public string? Credentials { 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) { AuthType = authType; Credentials = credentials; return Task.CompletedTask; } public Task SendAsync(string from, IEnumerable bccRecipients, string subject, string body, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } private static OAuth2TokenService CreateTokenService(string accessToken, int expiresIn = 3600) { var json = JsonSerializer.Serialize(new { access_token = accessToken, expires_in = expiresIn, token_type = "Bearer" }); var factory = Substitute.For(); factory.CreateClient(Arg.Any()) .Returns(_ => new HttpClient(new StubHttpHandler(json))); return new OAuth2TokenService(factory, NullLogger.Instance); } private sealed class StubHttpHandler : HttpMessageHandler { private readonly string _json; public StubHttpHandler(string json) => _json = json; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(_json) }); } [Fact] public async Task Send_OAuth2Config_AuthenticatesWithResolvedAccessToken() { // NS-012: the OAuth2 delivery branch in DeliverAsync (token resolution during // a send) was never exercised — every other test uses Basic Auth and a null // token service. The credentials reaching the SMTP client must be the access // token from OAuth2TokenService, not the raw tenant:client:secret triple. var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingAuthClient(); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance, tokenService: CreateTokenService("oauth2-access-token-xyz")); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.True(result.Success); Assert.Equal("oauth2", recording.AuthType); Assert.Equal("oauth2-access-token-xyz", recording.Credentials); } // ── NotificationService-015: unclassified exceptions must not escape SendAsync ── /// /// An whose token endpoint returns a non-success /// HTTP status, so GetTokenAsync throws . /// private static OAuth2TokenService CreateFailingTokenService( HttpStatusCode status = HttpStatusCode.Unauthorized) { var factory = Substitute.For(); factory.CreateClient(Arg.Any()) .Returns(_ => new HttpClient(new FailingHttpHandler(status))); return new OAuth2TokenService(factory, NullLogger.Instance); } private sealed class FailingHttpHandler : HttpMessageHandler { private readonly System.Net.HttpStatusCode _status; public FailingHttpHandler(System.Net.HttpStatusCode status) => _status = status; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent("error") }); } [Fact] public async Task Send_OAuth2TokenFetchFails_ReturnsCleanError_DoesNotThrow() { // NS-015: an OAuth2 token-fetch failure (HttpRequestException from // EnsureSuccessStatusCode) is classified Unknown — it fell through all three // catch clauses and escaped SendAsync as a raw exception to the calling // script. It must instead produce a clean NotificationResult failure. var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingAuthClient(), NullLogger.Instance, tokenService: CreateFailingTokenService()); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.NotNull(result.ErrorMessage); } [Fact] public async Task Send_OAuth2MalformedCredentials_ReturnsCleanError_DoesNotThrow() { // NS-015: a malformed tenant:client:secret triple makes GetTokenAsync throw // InvalidOperationException — also Unknown-classified, also escaped SendAsync. var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingAuthClient(), NullLogger.Instance, tokenService: CreateTokenService("unused")); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.NotNull(result.ErrorMessage); } [Fact] public async Task Send_UnclassifiedException_RedactsCredentialFromResult() { // NS-015: the catch-all result, like the permanent-error path (NS-009), must // not leak credential fragments echoed in an exception message. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("internal failure exposing Hunter2Secret")); var service = CreateService(); var result = await service.SendAsync("ops-team", "Alert", "Body"); Assert.False(result.Success); Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage); } // ── NotificationService-014: unclassified exceptions must not escape DeliverBufferedAsync ── [Fact] public async Task DeliverBuffered_OAuth2MalformedCredentials_ReturnsFalseSoMessageParks() { // NS-014: DeliverBufferedAsync caught only SmtpPermanentException. An OAuth2 // InvalidOperationException (a permanent, unfixable misconfiguration) escaped // the handler; the S&F engine reinterprets any thrown exception as transient // and retries forever. A non-retryable cause must park (return false). var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingAuthClient(), NullLogger.Instance, tokenService: CreateTokenService("unused")); var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team")); Assert.False(delivered); // parked — retrying cannot fix a malformed credential } [Fact] public async Task DeliverBuffered_OAuth2TokenEndpoint401_ReturnsFalseSoMessageParks() { // NS-014: a 401 from the OAuth2 token endpoint is a permanent credential // failure — it must park, not be retried on every sweep. var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingAuthClient(), NullLogger.Instance, tokenService: CreateFailingTokenService(HttpStatusCode.Unauthorized)); var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team")); Assert.False(delivered); // parked — a 401 is a permanent credential failure } [Fact] public async Task DeliverBuffered_OAuth2TokenEndpoint503_ThrowsSoEngineRetries() { // NS-014: a 5xx from the OAuth2 token endpoint is a transient outage — the // handler must throw so the S&F engine retries on the next sweep. var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com") { Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls" }; SetupHappyPathWithSmtp(cfg); var service = new NotificationDeliveryService( _repository, () => new RecordingAuthClient(), NullLogger.Instance, tokenService: CreateFailingTokenService(HttpStatusCode.ServiceUnavailable)); await Assert.ThrowsAnyAsync( () => service.DeliverBufferedAsync(BufferedNotification("ops-team"))); } // ── NotificationService-017: NotificationOptions used as fallback for unset SmtpConfiguration fields ── [Fact] public async Task Send_SmtpConfigTimeoutUnset_FallsBackToNotificationOptions() { // NS-017: NotificationOptions was bound from configuration but never read. // It is now the documented fallback: when SmtpConfiguration.ConnectionTimeoutSeconds // is non-positive (0 from a partially-deployed row) the NotificationOptions // value is used instead of leaving the timeout unbounded. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", ConnectionTimeoutSeconds = 0 // not configured on the row }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var options = Microsoft.Extensions.Options.Options.Create( new NotificationOptions { ConnectionTimeoutSeconds = 42, MaxConcurrentConnections = 5 }); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance, options: options); await service.SendAsync("ops-team", "Alert", "Body"); Assert.Equal(42, recording.ConnectionTimeoutSeconds); } [Fact] public async Task Send_SmtpConfigTimeoutSet_OverridesNotificationOptions() { // NS-017: a value present on the SmtpConfiguration row still wins over the // NotificationOptions fallback. var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com") { Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls", ConnectionTimeoutSeconds = 19 }; SetupHappyPathWithSmtp(cfg); var recording = new RecordingTlsClient(); var options = Microsoft.Extensions.Options.Options.Create( new NotificationOptions { ConnectionTimeoutSeconds = 42 }); var service = new NotificationDeliveryService( _repository, () => recording, NullLogger.Instance, options: options); await service.SendAsync("ops-team", "Alert", "Body"); Assert.Equal(19, recording.ConnectionTimeoutSeconds); } // ── NotificationService-018: concurrency limiter disposal ── [Fact] public async Task Service_Dispose_DisposesConcurrencyLimiter() { // NS-018: the lazily-created SemaphoreSlim was never disposed and the service // did not implement IDisposable — a slow handle leak per scope. Disposing the // service must dispose the limiter; using it afterwards must fault. SetupHappyPath(); var service = CreateService(); // A send creates the limiter. await service.SendAsync("ops-team", "Alert", "Body"); Assert.IsAssignableFrom(service); ((IDisposable)service).Dispose(); // A second send after disposal must fail fast on the disposed semaphore // rather than silently using a disposed object. await Assert.ThrowsAnyAsync( () => service.SendAsync("ops-team", "Alert", "Body")); } }