fix(notification-service): resolve NotificationService-005..009 — explicit TLS modes, per-credential token cache, timeout/throttle, address validation, credential redaction
This commit is contained in:
@@ -111,7 +111,8 @@ public class NotificationDeliveryServiceTests
|
||||
|
||||
await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
await _smtpClient.Received().ConnectAsync("smtp.example.com", 587, true, Arg.Any<CancellationToken>());
|
||||
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",
|
||||
@@ -363,7 +364,7 @@ public class NotificationDeliveryServiceTests
|
||||
private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
public Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default)
|
||||
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;
|
||||
@@ -405,4 +406,240 @@ public class NotificationDeliveryServiceTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user