Files
scadalink-design/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs

1034 lines
44 KiB
C#

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;
/// <summary>
/// WP-11/12: Tests for notification delivery — SMTP delivery, error classification, S&amp;F integration.
/// </summary>
public class NotificationDeliveryServiceTests
{
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();
private NotificationDeliveryService CreateService(StoreAndForward.StoreAndForwardService? sf = null)
{
return new NotificationDeliveryService(
_repository,
() => _smtpClient,
NullLogger<NotificationDeliveryService>.Instance,
tokenService: null,
storeAndForward: sf);
}
private void SetupHappyPath()
{
var list = new NotificationList("ops-team") { Id = 1 };
var recipients = new List<NotificationRecipient>
{
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<SmtpConfiguration> { 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<NotificationRecipient>());
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<NotificationRecipient>
{
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
};
_repository.GetListByNameAsync("test").Returns(list);
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());
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<int>(), Arg.Any<CancellationToken>());
await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
await _smtpClient.Received().SendAsync(
"noreply@example.com",
Arg.Is<IEnumerable<string>>(bcc => bcc.Count() == 2),
"Alert",
"Body",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Send_PermanentSmtpError_ReturnsErrorDirectly()
{
SetupHappyPath();
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string>? capturedBcc = null;
_smtpClient.SendAsync(
Arg.Any<string>(),
Arg.Do<IEnumerable<string>>(bcc => capturedBcc = bcc),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<StoreAndForward.StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
var sfOptions = new StoreAndForward.StoreAndForwardOptions();
var sfService = new StoreAndForward.StoreAndForwardService(
storage, sfOptions, NullLogger<StoreAndForward.StoreAndForwardService>.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 ──
/// <summary>
/// Like <see cref="SetupHappyPath"/> but matches any <see cref="CancellationToken"/>,
/// so tests that pass an already-cancelled token still resolve the list/recipients.
/// </summary>
private void SetupHappyPathAnyToken()
{
var list = new NotificationList("ops-team") { Id = 1 };
var recipients = new List<NotificationRecipient>
{
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<CancellationToken>()).Returns(list);
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>()).Returns(recipients);
_repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<SmtpConfiguration> { smtpConfig });
}
[Fact]
public async Task Send_CancellationRequested_PropagatesAndDoesNotBuffer()
{
SetupHappyPathAnyToken();
using var cts = new CancellationTokenSource();
cts.Cancel();
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new OperationCanceledException(cts.Token));
var sfService = await CreateSfServiceAsync();
var service = CreateService(sf: sfService);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => 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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new TaskCanceledException());
var service = CreateService(sf: null);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => 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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string> 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<TrackingSmtpClient>();
var service = new NotificationDeliveryService(
_repository,
() =>
{
var c = new TrackingSmtpClient();
created.Add(c);
return c;
},
NullLogger<NotificationDeliveryService>.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<StoreAndForwardService> CreateSfServiceAsync()
{
var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared";
var storage = new StoreAndForwardStorage(
$"Data Source={dbName}", NullLogger<StoreAndForwardStorage>.Instance);
await storage.InitializeAsync();
return new StoreAndForwardService(
storage, new StoreAndForwardOptions(), NullLogger<StoreAndForwardService>.Instance);
}
// ── NotificationService-010: SMTP client is disconnected on the failure path ──
/// <summary>
/// An SMTP wrapper that records whether <see cref="DisconnectAsync"/> ran and
/// can be told to fail at a chosen stage of the delivery sequence.
/// </summary>
private sealed class DisconnectTrackingClient : ISmtpClientWrapper, IDisposable
{
private readonly Func<Exception>? _failOnSend;
private readonly Func<Exception>? _failOnAuthenticate;
public DisconnectTrackingClient(
Func<Exception>? failOnSend = null, Func<Exception>? 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<string> 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<NotificationDeliveryService>.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<NotificationDeliveryService>.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 ──
/// <summary>An SMTP wrapper that records the TLS mode and timeout it was connected with.</summary>
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<string> 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<NotificationRecipient>
{
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<SmtpConfiguration> { 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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<Task> _onSend;
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)
=> Task.CompletedTask;
public Task SendAsync(string from, IEnumerable<string> 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<NotificationRecipient>
{
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<SmtpConfiguration> { 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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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 ──
/// <summary>An SMTP wrapper that records the auth type and credentials it received.</summary>
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<string> 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<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>())
.Returns(_ => new HttpClient(new StubHttpHandler(json)));
return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
}
private sealed class StubHttpHandler : HttpMessageHandler
{
private readonly string _json;
public StubHttpHandler(string json) => _json = json;
protected override Task<HttpResponseMessage> 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<NotificationDeliveryService>.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 ──
/// <summary>
/// An <see cref="OAuth2TokenService"/> whose token endpoint returns a non-success
/// HTTP status, so <c>GetTokenAsync</c> throws <see cref="HttpRequestException"/>.
/// </summary>
private static OAuth2TokenService CreateFailingTokenService(
HttpStatusCode status = HttpStatusCode.Unauthorized)
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>())
.Returns(_ => new HttpClient(new FailingHttpHandler(status)));
return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
}
private sealed class FailingHttpHandler : HttpMessageHandler
{
private readonly System.Net.HttpStatusCode _status;
public FailingHttpHandler(System.Net.HttpStatusCode status) => _status = status;
protected override Task<HttpResponseMessage> 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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<NotificationDeliveryService>.Instance,
tokenService: CreateFailingTokenService(HttpStatusCode.ServiceUnavailable));
await Assert.ThrowsAnyAsync<Exception>(
() => 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<NotificationDeliveryService>.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<NotificationDeliveryService>.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<IDisposable>(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<ObjectDisposedException>(
() => service.SendAsync("ops-team", "Alert", "Body"));
}
}