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:
Joseph Doherty
2026-05-16 21:22:01 -04:00
parent 57679d49f2
commit a702cb96a8
11 changed files with 791 additions and 41 deletions

View File

@@ -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);
}
}