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

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

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

View File

@@ -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>

View File

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