646 lines
27 KiB
C#
646 lines
27 KiB
C#
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&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_NotPromotedToPermanent()
|
|
{
|
|
// 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 NotificationResult. Classification now uses MailKit's
|
|
// typed exceptions only, so a non-SMTP exception is no longer misclassified.
|
|
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();
|
|
|
|
// The exception is not classified at all (not a typed SMTP failure); it
|
|
// surfaces rather than being mistaken for a permanent 5xx rejection.
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.SendAsync("ops-team", "Alert", "Body"));
|
|
}
|
|
|
|
[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-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);
|
|
}
|
|
}
|