refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.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()
|
||||
{
|
||||
// Password 'Hunter2pass!word' is 16 chars (>= MinSecretLength=12) and
|
||||
// therefore qualifies as a redactable secret-shaped trailing component.
|
||||
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pass!word'";
|
||||
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pass!word");
|
||||
|
||||
Assert.DoesNotContain("Hunter2pass!word", result);
|
||||
Assert.DoesNotContain("svc:Hunter2pass!word", 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"));
|
||||
}
|
||||
|
||||
// --- NS-025: don't over-mask short non-secret components ---
|
||||
|
||||
[Fact]
|
||||
public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString()
|
||||
{
|
||||
// 'root' is the Basic Auth user name — short, common, and absolutely
|
||||
// not a secret. It must NOT be masked when it appears in unrelated
|
||||
// diagnostic text like a file path.
|
||||
var text = "Config file at /root/.config/scada.conf was not found.";
|
||||
var result = CredentialRedactor.Scrub(text, "root:hunter2longenoughpwd");
|
||||
|
||||
Assert.Contains("/root/.config", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_TenantId_IsNotMaskedOutsidePackedString()
|
||||
{
|
||||
// The tenant id is not secret — only the client secret is. A tenant id
|
||||
// appearing in unrelated text (e.g. an error-code suffix) must survive.
|
||||
var text = "Error code tnt-1234567890-abcd reported by upstream";
|
||||
var result = CredentialRedactor.Scrub(text, "tnt-1234567890-abcd:cli-guid:RealClientSecretLongEnough");
|
||||
|
||||
Assert.Contains("tnt-1234567890-abcd", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength()
|
||||
{
|
||||
// Even a short packed string must be masked when it appears verbatim —
|
||||
// that exact appearance can only come from the credential itself.
|
||||
var text = "Auth bundle was rejected: u:p";
|
||||
var result = CredentialRedactor.Scrub(text, "u:p");
|
||||
|
||||
Assert.DoesNotContain("u:p", result);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-016: <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never
|
||||
/// silently skip authentication for a misconfigured SMTP config — a missing
|
||||
/// credential, an unrecognised auth type, or an unparseable Basic credential
|
||||
/// must be a hard, surfaced error rather than an unauthenticated send.
|
||||
/// NS-021: the OAuth2 (XOAUTH2) branch must carry a non-empty user identity
|
||||
/// (the SMTP From address) — an empty user is rejected by M365 with `535 5.7.3`.
|
||||
/// </summary>
|
||||
public class MailKitSmtpClientWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Authenticate_EmptyCredentials_Throws()
|
||||
{
|
||||
// An AuthType of "basic"/"oauth2" with a null/empty Credentials value is a
|
||||
// misconfigured row; the wrapper used to "return" and send unauthenticated.
|
||||
var wrapper = new MailKitSmtpClientWrapper();
|
||||
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("basic", null));
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("oauth2", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_UnknownAuthType_Throws()
|
||||
{
|
||||
// The switch had cases only for "basic"/"oauth2" and no default — any other
|
||||
// value (typo, future "ntlm") fell through and sent unauthenticated.
|
||||
var wrapper = new MailKitSmtpClientWrapper();
|
||||
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("ntlm", "user:pass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_BasicCredentialWithoutColon_Throws()
|
||||
{
|
||||
// A "basic" credential string that does not split into exactly two parts was
|
||||
// silently skipped — the connection then sent unauthenticated.
|
||||
var wrapper = new MailKitSmtpClientWrapper();
|
||||
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("basic", "nocolon"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_OAuth2WithoutUserName_Throws()
|
||||
{
|
||||
// NS-021: passing an OAuth2 access token but no user identity (FromAddress)
|
||||
// used to construct `new SaslMechanismOAuth2("", credentials)`, which M365
|
||||
// rejects with `535 5.7.3`. The wrapper now refuses upfront so the caller
|
||||
// sees a clean configuration error rather than a confusing server reject.
|
||||
var wrapper = new MailKitSmtpClientWrapper();
|
||||
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: null));
|
||||
await Assert.ThrowsAsync<SmtpPermanentException>(
|
||||
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XOAuth2InitialResponse_CarriesUserAndBearer()
|
||||
{
|
||||
// NS-021 regression guard: independent of the wrapper, prove that MailKit's
|
||||
// SaslMechanismOAuth2 puts `user=<userName>` into the initial-response bytes
|
||||
// — i.e. wiring the wrapper to pass `FromAddress` is sufficient to fix the
|
||||
// M365 handshake. If MailKit ever changes the framing this test will catch it.
|
||||
var sasl = new SaslMechanismOAuth2("noreply@example.com", "tok-xyz");
|
||||
|
||||
var initial = sasl.Challenge(string.Empty);
|
||||
var asString = Encoding.UTF8.GetString(Convert.FromBase64String(initial));
|
||||
|
||||
Assert.Contains("user=noreply@example.com", asString);
|
||||
Assert.Contains("auth=Bearer tok-xyz", asString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Tests for NotificationOptions defaults.
|
||||
/// </summary>
|
||||
public class NotificationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_HasReasonableDefaults()
|
||||
{
|
||||
var options = new NotificationOptions();
|
||||
|
||||
Assert.Equal(30, options.ConnectionTimeoutSeconds);
|
||||
Assert.Equal(5, options.MaxConcurrentConnections);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OAuth2 token flow — token acquisition, caching, and credential parsing.
|
||||
/// </summary>
|
||||
public class OAuth2TokenServiceTests
|
||||
{
|
||||
private static HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string responseJson)
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(statusCode, responseJson);
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
|
||||
private static IHttpClientFactory CreateMockFactory(HttpClient client)
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
factory.CreateClient(Arg.Any<string>()).Returns(client);
|
||||
return factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ReturnsAccessToken_FromTokenEndpoint()
|
||||
{
|
||||
var tokenResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
access_token = "mock-access-token-12345",
|
||||
expires_in = 3600,
|
||||
token_type = "Bearer"
|
||||
});
|
||||
|
||||
var client = CreateMockHttpClient(HttpStatusCode.OK, tokenResponse);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var token = await service.GetTokenAsync("tenant123:client456:secret789");
|
||||
|
||||
Assert.Equal("mock-access-token-12345", token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_CachesToken_OnSubsequentCalls()
|
||||
{
|
||||
var tokenResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
access_token = "cached-token",
|
||||
expires_in = 3600,
|
||||
token_type = "Bearer"
|
||||
});
|
||||
|
||||
var handler = new CountingHttpMessageHandler(HttpStatusCode.OK, tokenResponse);
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var token1 = await service.GetTokenAsync("tenant:client:secret");
|
||||
var token2 = await service.GetTokenAsync("tenant:client:secret");
|
||||
|
||||
Assert.Equal("cached-token", token1);
|
||||
Assert.Equal("cached-token", token2);
|
||||
Assert.Equal(1, handler.CallCount); // Only one HTTP call should be made
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_InvalidCredentialFormat_ThrowsInvalidOperationException()
|
||||
{
|
||||
var client = CreateMockHttpClient(HttpStatusCode.OK, "{}");
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.GetTokenAsync("invalid-no-colons"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_HttpFailure_ThrowsHttpRequestException()
|
||||
{
|
||||
var client = CreateMockHttpClient(HttpStatusCode.Unauthorized, "Unauthorized");
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => 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
|
||||
}
|
||||
|
||||
// ── NotificationService-012: token expiry/refresh and concurrent acquisition ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ExpiredToken_RefreshesOnNextCall()
|
||||
{
|
||||
// NS-012: token expiry/refresh was untested — the cache test used a 3600s
|
||||
// token so the refresh branch never ran. The service refreshes 60s before
|
||||
// the stated expiry, so an expires_in of 60 makes the token immediately
|
||||
// stale and the next call must fetch a fresh one.
|
||||
var handler = new SequenceHttpMessageHandler(
|
||||
TokenJson("first-token", expiresIn: 60),
|
||||
TokenJson("second-token", expiresIn: 3600));
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var token1 = await service.GetTokenAsync("tenant:client:secret");
|
||||
var token2 = await service.GetTokenAsync("tenant:client:secret");
|
||||
|
||||
Assert.Equal("first-token", token1);
|
||||
Assert.Equal("second-token", token2); // refreshed because the first was already stale
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ConcurrentCalls_MakeExactlyOneHttpRequest()
|
||||
{
|
||||
// NS-012: the double-checked-locking path was never exercised. Many callers
|
||||
// racing for the same uncached credential must collapse to a single token
|
||||
// fetch, not one HTTP call per caller.
|
||||
var handler = new SlowCountingHttpMessageHandler(
|
||||
TokenJson("concurrent-token", expiresIn: 3600), delay: TimeSpan.FromMilliseconds(100));
|
||||
var client = new HttpClient(handler);
|
||||
var factory = CreateMockFactory(client);
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => service.GetTokenAsync("tenant:client:secret"))
|
||||
.ToArray();
|
||||
var tokens = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.All(tokens, t => Assert.Equal("concurrent-token", t));
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
private static string TokenJson(string accessToken, int expiresIn) =>
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
access_token = accessToken,
|
||||
expires_in = expiresIn,
|
||||
token_type = "Bearer"
|
||||
});
|
||||
|
||||
/// <summary>HTTP handler returning a different response per invocation, in order.</summary>
|
||||
private class SequenceHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string[] _responses;
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public SequenceHttpMessageHandler(params string[] responses) => _responses = responses;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = _responses[Math.Min(CallCount, _responses.Length - 1)];
|
||||
CallCount++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP handler that delays and counts invocations (thread-safe count).</summary>
|
||||
private class SlowCountingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _response;
|
||||
private readonly TimeSpan _delay;
|
||||
private int _callCount;
|
||||
public int CallCount => _callCount;
|
||||
|
||||
public SlowCountingHttpMessageHandler(string response, TimeSpan delay)
|
||||
{
|
||||
_response = response;
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
await Task.Delay(_delay, cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(_response)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _responseContent;
|
||||
|
||||
public MockHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_responseContent = responseContent;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_responseContent)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP handler that counts invocations.
|
||||
/// </summary>
|
||||
private class CountingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _responseContent;
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public CountingHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_responseContent = responseContent;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_responseContent)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Net.Sockets;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-002/NS-003: Tests for the shared SMTP error classification policy. This
|
||||
/// policy is correctness-relevant — it decides whether a delivery failure is
|
||||
/// retried (transient) or returned to the caller (permanent) — and is shared
|
||||
/// between <see cref="NotificationDeliveryService"/> and the central outbox's
|
||||
/// <c>EmailNotificationDeliveryAdapter</c>, so it deserves direct coverage.
|
||||
/// </summary>
|
||||
public class SmtpErrorClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(421)] // service not available
|
||||
[InlineData(450)] // mailbox unavailable (busy)
|
||||
[InlineData(451)] // local error in processing
|
||||
[InlineData(452)] // insufficient system storage
|
||||
public void Classify_Smtp4xxCommand_IsTransient(int statusCode)
|
||||
{
|
||||
var ex = new SmtpCommandException(
|
||||
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
|
||||
|
||||
Assert.Equal(SmtpErrorClass.Transient, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(500)] // syntax error
|
||||
[InlineData(550)] // mailbox unavailable (rejected)
|
||||
[InlineData(553)] // mailbox name not allowed
|
||||
[InlineData(554)] // transaction failed
|
||||
public void Classify_Smtp5xxCommand_IsPermanent(int statusCode)
|
||||
{
|
||||
var ex = new SmtpCommandException(
|
||||
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "rejected");
|
||||
|
||||
Assert.Equal(SmtpErrorClass.Permanent, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_SmtpCommandWithUnusualCode_IsUnknown()
|
||||
{
|
||||
// A status code outside the 4xx/5xx bands is not classifiable.
|
||||
var ex = new SmtpCommandException(
|
||||
SmtpErrorCode.UnexpectedStatusCode, (SmtpStatusCode)250, "ok-ish");
|
||||
|
||||
Assert.Equal(SmtpErrorClass.Unknown, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_SmtpProtocolException_IsTransient()
|
||||
{
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Transient,
|
||||
SmtpErrorClassifier.Classify(new SmtpProtocolException("protocol error"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_ServiceNotConnectedException_IsTransient()
|
||||
{
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Transient,
|
||||
SmtpErrorClassifier.Classify(new ServiceNotConnectedException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_SocketException_IsTransient()
|
||||
{
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Transient,
|
||||
SmtpErrorClassifier.Classify(new SocketException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TimeoutException_IsTransient()
|
||||
{
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Transient,
|
||||
SmtpErrorClassifier.Classify(new TimeoutException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_RequestedCancellation_IsUnknown()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Unknown,
|
||||
SmtpErrorClassifier.Classify(new OperationCanceledException(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_OperationCanceledWithoutRequestedCancellation_IsUnknown()
|
||||
{
|
||||
// Not a recognised SMTP error, and cancellation was not requested.
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Unknown,
|
||||
SmtpErrorClassifier.Classify(new OperationCanceledException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_UnrecognisedException_IsUnknown()
|
||||
{
|
||||
Assert.Equal(
|
||||
SmtpErrorClass.Unknown,
|
||||
SmtpErrorClassifier.Classify(new InvalidOperationException("bad credential triple"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(450, true)]
|
||||
[InlineData(550, false)]
|
||||
[InlineData(250, false)]
|
||||
public void IsTransient_MatchesClassification(int statusCode, bool expectedTransient)
|
||||
{
|
||||
var ex = new SmtpCommandException(
|
||||
SmtpErrorCode.MessageNotAccepted, (SmtpStatusCode)statusCode, "x");
|
||||
|
||||
Assert.Equal(expectedTransient, SmtpErrorClassifier.IsTransient(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTransient_RequestedCancellation_IsFalse()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.False(SmtpErrorClassifier.IsTransient(new OperationCanceledException(), cts.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.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));
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user