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:
@@ -0,0 +1,38 @@
|
||||
namespace ScadaLink.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text.
|
||||
/// </summary>
|
||||
public class CredentialRedactorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scrub_BasicAuthPassword_IsMasked()
|
||||
{
|
||||
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pw!'";
|
||||
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pw!");
|
||||
|
||||
Assert.DoesNotContain("Hunter2pw!", result);
|
||||
Assert.DoesNotContain("svc:Hunter2pw!", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_OAuth2ClientSecret_IsMasked()
|
||||
{
|
||||
var text = "Token request failed: client_secret=Sup3rSecretValue rejected by tenant";
|
||||
var result = CredentialRedactor.Scrub(text, "tenant-guid:client-guid:Sup3rSecretValue");
|
||||
|
||||
Assert.DoesNotContain("Sup3rSecretValue", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_NullCredentials_ReturnsTextUnchanged()
|
||||
{
|
||||
Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_NullText_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,70 @@ public class OAuth2TokenServiceTests
|
||||
() => service.GetTokenAsync("tenant:client:secret"));
|
||||
}
|
||||
|
||||
// ── NotificationService-006: token cache must be keyed to credential identity ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens()
|
||||
{
|
||||
// NS-006: the singleton cached a single token ignoring the credentials
|
||||
// argument, so a second SMTP config with a different tenant/client got the
|
||||
// first config's token. Each distinct credential must get its own token.
|
||||
var handler = new PerTenantHttpMessageHandler();
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var tokenA = await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||
var tokenB = await service.GetTokenAsync("tenantB:clientB:secretB");
|
||||
|
||||
Assert.Equal("token-for-tenantA", tokenA);
|
||||
Assert.Equal("token-for-tenantB", tokenB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_SameCredentials_CachedPerCredential()
|
||||
{
|
||||
// NS-006: caching still works — repeated calls with the same credential
|
||||
// identity make exactly one HTTP call.
|
||||
var handler = new PerTenantHttpMessageHandler();
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||
await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||
await service.GetTokenAsync("tenantB:clientB:secretB");
|
||||
|
||||
Assert.Equal(2, handler.CallCount); // one per distinct credential, not per call
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP handler that returns a distinct access token per tenant id, parsed from
|
||||
/// the request URL (<c>https://login.microsoftonline.com/{tenantId}/...</c>).
|
||||
/// </summary>
|
||||
private class PerTenantHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
var segments = request.RequestUri!.AbsolutePath.Trim('/').Split('/');
|
||||
var tenantId = segments[0];
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
access_token = $"token-for-{tenantId}",
|
||||
expires_in = 3600,
|
||||
token_type = "Bearer"
|
||||
});
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple mock HTTP handler that returns a fixed response.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ScadaLink.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-005: Tests for parsing the configured SMTP TLS mode into the three-state enum.
|
||||
/// </summary>
|
||||
public class SmtpTlsModeParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("none", SmtpTlsMode.None)]
|
||||
[InlineData("None", SmtpTlsMode.None)]
|
||||
[InlineData("NONE", SmtpTlsMode.None)]
|
||||
[InlineData("starttls", SmtpTlsMode.StartTls)]
|
||||
[InlineData("StartTLS", SmtpTlsMode.StartTls)]
|
||||
[InlineData("ssl", SmtpTlsMode.Ssl)]
|
||||
[InlineData("SSL", SmtpTlsMode.Ssl)]
|
||||
[InlineData(" starttls ", SmtpTlsMode.StartTls)]
|
||||
public void Parse_KnownModes_ReturnsExpected(string input, SmtpTlsMode expected)
|
||||
{
|
||||
Assert.Equal(expected, SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Parse_NullOrEmpty_DefaultsToStartTls(string? input)
|
||||
{
|
||||
Assert.Equal(SmtpTlsMode.StartTls, SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("auto")]
|
||||
[InlineData("tls")]
|
||||
[InlineData("implicit")]
|
||||
public void Parse_UnknownMode_Throws(string input)
|
||||
{
|
||||
// NS-005: an unknown mode must be rejected, not silently treated as Auto.
|
||||
Assert.Throws<ArgumentException>(() => SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user