docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text.
|
||||
/// Tests for scrubbing SMTP credential secrets out of log/result text.
|
||||
/// </summary>
|
||||
public class CredentialRedactorTests
|
||||
{
|
||||
/// <summary>Verifies that a Basic auth password is masked.</summary>
|
||||
[Fact]
|
||||
public void Scrub_BasicAuthPassword_IsMasked()
|
||||
{
|
||||
@@ -17,6 +18,7 @@ public class CredentialRedactorTests
|
||||
Assert.DoesNotContain("svc:Hunter2pass!word", result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an OAuth2 client secret is masked.</summary>
|
||||
[Fact]
|
||||
public void Scrub_OAuth2ClientSecret_IsMasked()
|
||||
{
|
||||
@@ -26,20 +28,23 @@ public class CredentialRedactorTests
|
||||
Assert.DoesNotContain("Sup3rSecretValue", result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null credentials leave the text unchanged.</summary>
|
||||
[Fact]
|
||||
public void Scrub_NullCredentials_ReturnsTextUnchanged()
|
||||
{
|
||||
Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scrubbing null text returns an empty string.</summary>
|
||||
[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 ---
|
||||
// --- Don't over-mask short non-secret components ---
|
||||
|
||||
/// <summary>Verifies that a short user name is not masked outside the packed credential string.</summary>
|
||||
[Fact]
|
||||
public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString()
|
||||
{
|
||||
@@ -52,6 +57,7 @@ public class CredentialRedactorTests
|
||||
Assert.Contains("/root/.config", result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tenant ID is not masked outside the packed credential string.</summary>
|
||||
[Fact]
|
||||
public void Scrub_TenantId_IsNotMaskedOutsidePackedString()
|
||||
{
|
||||
@@ -63,6 +69,7 @@ public class CredentialRedactorTests
|
||||
Assert.Contains("tnt-1234567890-abcd", result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a full packed credential is always masked regardless of its length.</summary>
|
||||
[Fact]
|
||||
public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
/// </summary>
|
||||
public class EmailTransportParserTests
|
||||
{
|
||||
/// <summary>Verifies that parsing a known transport name returns the expected transport.</summary>
|
||||
/// <param name="input">The input text to parse.</param>
|
||||
/// <param name="expected">The expected parsed transport.</param>
|
||||
[Theory]
|
||||
[InlineData("Smtp", EmailTransport.Smtp)]
|
||||
[InlineData("smtp", EmailTransport.Smtp)]
|
||||
@@ -18,6 +21,8 @@ public class EmailTransportParserTests
|
||||
Assert.Equal(expected, EmailTransportParser.Parse(input));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing a null or empty value defaults to SMTP.</summary>
|
||||
/// <param name="input">The input text to parse.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -28,6 +33,8 @@ public class EmailTransportParserTests
|
||||
Assert.Equal(EmailTransport.Smtp, EmailTransportParser.Parse(input));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing an unknown transport name throws.</summary>
|
||||
/// <param name="input">The input text to parse.</param>
|
||||
[Theory]
|
||||
[InlineData("Graph")]
|
||||
[InlineData("smtps")]
|
||||
|
||||
@@ -73,6 +73,7 @@ public class EwsResponseParserTests
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
/// <summary>Verifies that a success response parses to a success result with no error.</summary>
|
||||
[Fact]
|
||||
public void Parse_SuccessResponse_ReturnsSuccessWithNoError()
|
||||
{
|
||||
@@ -82,6 +83,7 @@ public class EwsResponseParserTests
|
||||
Assert.Equal("NoError", result.ResponseCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an error response parses to an error result with its code and message.</summary>
|
||||
[Fact]
|
||||
public void Parse_ErrorResponse_ReturnsErrorWithCodeAndMessage()
|
||||
{
|
||||
@@ -92,6 +94,7 @@ public class EwsResponseParserTests
|
||||
Assert.Equal("server busy", result.MessageText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a SOAP fault with a detail response code parses to a fault carrying that code.</summary>
|
||||
[Fact]
|
||||
public void Parse_SoapFaultWithDetailResponseCode_ReturnsFaultWithCode()
|
||||
{
|
||||
@@ -102,6 +105,7 @@ public class EwsResponseParserTests
|
||||
Assert.Equal("The request failed schema validation.", result.MessageText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a SOAP fault without a response code parses to a fault with a null code.</summary>
|
||||
[Fact]
|
||||
public void Parse_SoapFaultWithoutResponseCode_ReturnsFaultWithNullCode()
|
||||
{
|
||||
@@ -112,6 +116,7 @@ public class EwsResponseParserTests
|
||||
Assert.Equal("An internal server error occurred.", result.MessageText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a payload containing a DTD entity declaration is rejected as unparseable.</summary>
|
||||
[Fact]
|
||||
public void Parse_PayloadWithDtdEntityDeclaration_IsRejectedAsUnparseable()
|
||||
{
|
||||
@@ -152,6 +157,8 @@ public class EwsResponseParserTests
|
||||
Assert.Null(result.MessageText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a garbage or unrelated payload is reported as unparseable.</summary>
|
||||
/// <param name="body">The response body under test.</param>
|
||||
[Theory]
|
||||
[InlineData("not xml")]
|
||||
[InlineData("")]
|
||||
|
||||
@@ -21,6 +21,7 @@ public class EwsSoapEnvelopeTests
|
||||
string body = "Body")
|
||||
=> XDocument.Parse(EwsSoapEnvelope.BuildCreateItem(from, bcc ?? ["one@example.com"], subject, body));
|
||||
|
||||
/// <summary>Verifies that the envelope sets the send-only disposition and the Exchange 2013 request version.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_SetsSendOnlyDispositionAndExchange2013RequestVersion()
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public class EwsSoapEnvelopeTests
|
||||
Assert.Single(doc.Descendants(Soap + "Header").Single().Elements());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that markup in the subject and body is escaped.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_EscapesMarkupInSubjectAndBody()
|
||||
{
|
||||
@@ -48,6 +50,7 @@ public class EwsSoapEnvelopeTests
|
||||
Assert.Equal("Text", (string?)doc.Descendants(Types + "Body").Single().Attribute("BodyType"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every recipient is placed in the BCC list and no To recipients are emitted.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_PutsEveryRecipientInBccAndNeverEmitsToRecipients()
|
||||
{
|
||||
@@ -65,6 +68,7 @@ public class EwsSoapEnvelopeTests
|
||||
Assert.DoesNotContain(doc.Descendants(), e => e.Name.LocalName is "ToRecipients" or "CcRecipients");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the envelope emits the From mailbox.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_EmitsFromMailbox()
|
||||
{
|
||||
@@ -75,6 +79,7 @@ public class EwsSoapEnvelopeTests
|
||||
Assert.Equal("scada-alerts@example.com", mailbox.Element(Types + "EmailAddress")!.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the message child elements follow the EWS schema sequence.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_OrdersMessageChildrenPerEwsSchemaSequence()
|
||||
{
|
||||
@@ -87,6 +92,7 @@ public class EwsSoapEnvelopeTests
|
||||
message.Elements().Select(e => e.Name.LocalName).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the envelope starts with a UTF-8 XML declaration.</summary>
|
||||
[Fact]
|
||||
public void BuildCreateItem_StartsWithUtf8XmlDeclaration()
|
||||
{
|
||||
|
||||
@@ -72,6 +72,8 @@ public class EwsSoapMailSenderTests
|
||||
</s:Envelope>
|
||||
""";
|
||||
|
||||
/// <summary>Verifies that a success response completes the send and the expected request was sent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
|
||||
{
|
||||
@@ -88,6 +90,8 @@ public class EwsSoapMailSenderTests
|
||||
Assert.Contains(Recipient, handler.CapturedContent);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-HTTPS endpoint throws a permanent error without sending credentials.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials()
|
||||
{
|
||||
@@ -104,6 +108,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a server-busy response body throws a transient error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
|
||||
{
|
||||
@@ -116,6 +122,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unauthorized response throws a permanent error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_Unauthorized_ThrowsPermanent()
|
||||
{
|
||||
@@ -128,6 +136,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a schema fault on an HTTP 500 response throws a permanent error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent()
|
||||
{
|
||||
@@ -142,6 +152,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bare service-unavailable response throws a transient error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_BareServiceUnavailable_ThrowsTransient()
|
||||
{
|
||||
@@ -154,6 +166,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a transport failure throws a transient error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_TransportFailure_ThrowsTransient()
|
||||
{
|
||||
@@ -166,6 +180,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a request exceeding the timeout throws a transient error rather than a cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation()
|
||||
{
|
||||
@@ -178,6 +194,8 @@ public class EwsSoapMailSenderTests
|
||||
AssertNoCredentialLeak(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation propagates unwrapped.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped()
|
||||
{
|
||||
@@ -193,6 +211,8 @@ public class EwsSoapMailSenderTests
|
||||
Assert.IsNotType<EwsPermanentException>(ex);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a success status with an unparseable body throws a permanent error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent()
|
||||
{
|
||||
@@ -242,6 +262,9 @@ public class EwsSoapMailSenderTests
|
||||
/// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
|
||||
public string? RequestedClientName { get; private set; }
|
||||
|
||||
/// <summary>Creates an HTTP client over the fake message handler.</summary>
|
||||
/// <param name="name">The logical client name.</param>
|
||||
/// <returns>An HTTP client backed by the fake handler.</returns>
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
RequestedClientName = name;
|
||||
@@ -260,26 +283,40 @@ public class EwsSoapMailSenderTests
|
||||
private StubHandler(Func<CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
=> _responder = responder;
|
||||
|
||||
/// <summary>Gets the authorization scheme of the captured request.</summary>
|
||||
public string? CapturedScheme { get; private set; }
|
||||
|
||||
/// <summary>Gets the authorization parameter of the captured request.</summary>
|
||||
public string? CapturedParameter { get; private set; }
|
||||
|
||||
/// <summary>Gets the content media type of the captured request.</summary>
|
||||
public string? CapturedMediaType { get; private set; }
|
||||
|
||||
/// <summary>Gets the serialized body of the captured request.</summary>
|
||||
public string CapturedContent { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary>
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
/// <summary>Creates a handler that answers every request with the given status and XML body.</summary>
|
||||
/// <param name="status">The HTTP status code to return.</param>
|
||||
/// <param name="body">The XML response body.</param>
|
||||
/// <returns>A handler that returns the canned response.</returns>
|
||||
public static StubHandler Responding(HttpStatusCode status, string body)
|
||||
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "text/xml"),
|
||||
}));
|
||||
|
||||
/// <summary>Creates a handler that fails every request with the given exception.</summary>
|
||||
/// <param name="exception">The exception to throw.</param>
|
||||
/// <returns>A handler that throws.</returns>
|
||||
public static StubHandler Throwing(Exception exception)
|
||||
=> new(_ => Task.FromException<HttpResponseMessage>(exception));
|
||||
|
||||
/// <summary>Creates a handler that waits for the given delay before answering, so a timeout can trip.</summary>
|
||||
/// <param name="delay">How long to wait before responding.</param>
|
||||
/// <returns>A handler that responds after the delay.</returns>
|
||||
public static StubHandler Delaying(TimeSpan delay)
|
||||
=> new(async token =>
|
||||
{
|
||||
@@ -287,6 +324,7 @@ public class EwsSoapMailSenderTests
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
+16
-7
@@ -4,15 +4,17 @@ using MailKit.Security;
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-016: <see cref="MailKitSmtpClientWrapper.AuthenticateAsync"/> must never
|
||||
/// <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`.
|
||||
/// The OAuth2 (XOAUTH2) branch must carry a non-empty user identity
|
||||
/// (the SMTP From address) — an empty user is rejected by Microsoft 365 with `535 5.7.3`.
|
||||
/// </summary>
|
||||
public class MailKitSmtpClientWrapperTests
|
||||
{
|
||||
/// <summary>Verifies that authenticating with empty credentials throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_EmptyCredentials_Throws()
|
||||
{
|
||||
@@ -26,6 +28,8 @@ public class MailKitSmtpClientWrapperTests
|
||||
() => wrapper.AuthenticateAsync("oauth2", ""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticating with an unknown auth type throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_UnknownAuthType_Throws()
|
||||
{
|
||||
@@ -37,6 +41,8 @@ public class MailKitSmtpClientWrapperTests
|
||||
() => wrapper.AuthenticateAsync("ntlm", "user:pass"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a basic credential without a colon separator throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Authenticate_BasicCredentialWithoutColon_Throws()
|
||||
{
|
||||
@@ -48,11 +54,13 @@ public class MailKitSmtpClientWrapperTests
|
||||
() => wrapper.AuthenticateAsync("basic", "nocolon"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that OAuth2 authentication without a user name throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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
|
||||
// Passing an OAuth2 access token but no user identity (FromAddress)
|
||||
// used to construct `new SaslMechanismOAuth2("", credentials)`, which Microsoft 365
|
||||
// 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();
|
||||
@@ -63,13 +71,14 @@ public class MailKitSmtpClientWrapperTests
|
||||
() => wrapper.AuthenticateAsync("oauth2", "access-token", oauth2UserName: ""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the XOAUTH2 initial response carries both the user and the bearer token.</summary>
|
||||
[Fact]
|
||||
public void XOAuth2InitialResponse_CarriesUserAndBearer()
|
||||
{
|
||||
// NS-021 regression guard: independent of the wrapper, prove that MailKit's
|
||||
// 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.
|
||||
// Microsoft 365 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);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Tests for NotificationOptions defaults.
|
||||
/// Tests for NotificationOptions defaults.
|
||||
/// </summary>
|
||||
public class NotificationOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the default options have reasonable values.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_HasReasonableDefaults()
|
||||
{
|
||||
|
||||
+5
@@ -13,6 +13,7 @@ public class NotificationOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(NotificationOptions options) =>
|
||||
new NotificationOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
/// <summary>Verifies that the default options pass validation.</summary>
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
@@ -20,6 +21,7 @@ public class NotificationOptionsValidatorTests
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero connection timeout is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroConnectionTimeoutSeconds_IsRejected()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public class NotificationOptionsValidatorTests
|
||||
Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative connection timeout is rejected.</summary>
|
||||
[Fact]
|
||||
public void NegativeConnectionTimeoutSeconds_IsRejected()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public class NotificationOptionsValidatorTests
|
||||
Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a zero maximum concurrent connection count is rejected.</summary>
|
||||
[Fact]
|
||||
public void ZeroMaxConcurrentConnections_IsRejected()
|
||||
{
|
||||
@@ -47,6 +51,7 @@ public class NotificationOptionsValidatorTests
|
||||
Assert.Contains("MaxConcurrentConnections", result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a negative maximum concurrent connection count is rejected.</summary>
|
||||
[Fact]
|
||||
public void NegativeMaxConcurrentConnections_IsRejected()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ public class OAuth2TokenServiceTests
|
||||
return factory;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async returns access token from token endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ReturnsAccessToken_FromTokenEndpoint()
|
||||
{
|
||||
@@ -42,6 +44,8 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal("mock-access-token-12345", token);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async caches token on subsequent calls.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_CachesToken_OnSubsequentCalls()
|
||||
{
|
||||
@@ -65,6 +69,8 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal(1, handler.CallCount); // Only one HTTP call should be made
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async invalid credential format throws invalid operation exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_InvalidCredentialFormat_ThrowsInvalidOperationException()
|
||||
{
|
||||
@@ -76,6 +82,8 @@ public class OAuth2TokenServiceTests
|
||||
() => service.GetTokenAsync("invalid-no-colons"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async http failure throws http request exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_HttpFailure_ThrowsHttpRequestException()
|
||||
{
|
||||
@@ -87,12 +95,14 @@ public class OAuth2TokenServiceTests
|
||||
() => service.GetTokenAsync("tenant:client:secret"));
|
||||
}
|
||||
|
||||
// ── NotificationService-006: token cache must be keyed to credential identity ──
|
||||
// ── Token cache must be keyed to credential identity ──
|
||||
|
||||
/// <summary>Verifies that get token async different credentials return per credential tokens.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens()
|
||||
{
|
||||
// NS-006: the singleton cached a single token ignoring the credentials
|
||||
// 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();
|
||||
@@ -107,10 +117,12 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal("token-for-tenantB", tokenB);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async same credentials cached per credential.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_SameCredentials_CachedPerCredential()
|
||||
{
|
||||
// NS-006: caching still works — repeated calls with the same credential
|
||||
// Caching still works — repeated calls with the same credential
|
||||
// identity make exactly one HTTP call.
|
||||
var handler = new PerTenantHttpMessageHandler();
|
||||
var client = new HttpClient(handler);
|
||||
@@ -124,12 +136,14 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal(2, handler.CallCount); // one per distinct credential, not per call
|
||||
}
|
||||
|
||||
// ── NotificationService-012: token expiry/refresh and concurrent acquisition ──
|
||||
// ── Token expiry/refresh and concurrent acquisition ──
|
||||
|
||||
/// <summary>Verifies that get token async expired token refreshes on next call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ExpiredToken_RefreshesOnNextCall()
|
||||
{
|
||||
// NS-012: token expiry/refresh was untested — the cache test used a 3600s
|
||||
// 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.
|
||||
@@ -148,10 +162,12 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async concurrent calls make exactly one http request.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ConcurrentCalls_MakeExactlyOneHttpRequest()
|
||||
{
|
||||
// NS-012: the double-checked-locking path was never exercised. Many callers
|
||||
// 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(
|
||||
@@ -169,12 +185,14 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Task 19: configurable OAuth2 authority + scope per SMTP configuration ──
|
||||
// ── Configurable OAuth2 authority + scope per SMTP configuration ──
|
||||
|
||||
/// <summary>Verifies that get token async custom authority and scope posts to configured endpoint.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_CustomAuthorityAndScope_PostsToConfiguredEndpoint()
|
||||
{
|
||||
// A non-M365 IdP: the stored authority template carries a {tenant} placeholder
|
||||
// An IdP other than Microsoft 365: the stored authority template carries a {tenant} placeholder
|
||||
// and the scope is provider-specific. Both must flow verbatim into the request.
|
||||
var handler = new CapturingHttpMessageHandler(
|
||||
TokenJson("custom-token", expiresIn: 3600));
|
||||
@@ -192,6 +210,8 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal("smtp.send", handler.LastForm!["scope"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that get token async preserves the Microsoft 365 defaults.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_DefaultsPreserved_M365()
|
||||
{
|
||||
@@ -223,10 +243,14 @@ public class OAuth2TokenServiceTests
|
||||
private class SequenceHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string[] _responses;
|
||||
/// <summary>The number of HTTP requests the handler has served.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Initializes a new instance of <c>SequenceHttpMessageHandler</c>.</summary>
|
||||
/// <param name="responses">The responses.</param>
|
||||
public SequenceHttpMessageHandler(params string[] responses) => _responses = responses;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -245,14 +269,19 @@ public class OAuth2TokenServiceTests
|
||||
private readonly string _response;
|
||||
private readonly TimeSpan _delay;
|
||||
private int _callCount;
|
||||
/// <summary>The number of HTTP requests the handler has served.</summary>
|
||||
public int CallCount => _callCount;
|
||||
|
||||
/// <summary>Initializes a new instance of <c>SlowCountingHttpMessageHandler</c>.</summary>
|
||||
/// <param name="response">The response.</param>
|
||||
/// <param name="delay">The delay.</param>
|
||||
public SlowCountingHttpMessageHandler(string response, TimeSpan delay)
|
||||
{
|
||||
_response = response;
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -271,8 +300,10 @@ public class OAuth2TokenServiceTests
|
||||
/// </summary>
|
||||
private class PerTenantHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
/// <summary>The number of HTTP requests the handler has served.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -299,11 +330,16 @@ public class OAuth2TokenServiceTests
|
||||
private class CapturingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _response;
|
||||
/// <summary>The URI of the most recent request, if any.</summary>
|
||||
public Uri? LastRequestUri { get; private set; }
|
||||
/// <summary>The form fields posted in the most recent request, if any.</summary>
|
||||
public IReadOnlyDictionary<string, string>? LastForm { get; private set; }
|
||||
|
||||
/// <summary>Initializes a new instance of <c>CapturingHttpMessageHandler</c>.</summary>
|
||||
/// <param name="response">The response.</param>
|
||||
public CapturingHttpMessageHandler(string response) => _response = response;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -332,12 +368,16 @@ public class OAuth2TokenServiceTests
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _responseContent;
|
||||
|
||||
/// <summary>Initializes a new instance of <c>MockHttpMessageHandler</c>.</summary>
|
||||
/// <param name="statusCode">The status code.</param>
|
||||
/// <param name="responseContent">The response content.</param>
|
||||
public MockHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_responseContent = responseContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -355,14 +395,19 @@ public class OAuth2TokenServiceTests
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _responseContent;
|
||||
/// <summary>The number of HTTP requests the handler has served.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Initializes a new instance of <c>CountingHttpMessageHandler</c>.</summary>
|
||||
/// <param name="statusCode">The status code.</param>
|
||||
/// <param name="responseContent">The response content.</param>
|
||||
public CountingHttpMessageHandler(HttpStatusCode statusCode, string responseContent)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_responseContent = responseContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
/// 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
|
||||
/// by the central outbox's <c>EmailNotificationDeliveryAdapter</c>, so it
|
||||
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
/// </summary>
|
||||
public class SmtpErrorClassifierTests
|
||||
{
|
||||
/// <summary>Verifies that an SMTP 4xx command error is classified as transient.</summary>
|
||||
/// <param name="statusCode">The SMTP status code.</param>
|
||||
[Theory]
|
||||
[InlineData(421)] // service not available
|
||||
[InlineData(450)] // mailbox unavailable (busy)
|
||||
@@ -26,6 +28,8 @@ public class SmtpErrorClassifierTests
|
||||
Assert.Equal(SmtpErrorClass.Transient, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP 5xx command error is classified as permanent.</summary>
|
||||
/// <param name="statusCode">The SMTP status code.</param>
|
||||
[Theory]
|
||||
[InlineData(500)] // syntax error
|
||||
[InlineData(550)] // mailbox unavailable (rejected)
|
||||
@@ -39,6 +43,7 @@ public class SmtpErrorClassifierTests
|
||||
Assert.Equal(SmtpErrorClass.Permanent, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP command error with an unusual status code is classified as unknown.</summary>
|
||||
[Fact]
|
||||
public void Classify_SmtpCommandWithUnusualCode_IsUnknown()
|
||||
{
|
||||
@@ -49,6 +54,7 @@ public class SmtpErrorClassifierTests
|
||||
Assert.Equal(SmtpErrorClass.Unknown, SmtpErrorClassifier.Classify(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an SMTP protocol exception is classified as transient.</summary>
|
||||
[Fact]
|
||||
public void Classify_SmtpProtocolException_IsTransient()
|
||||
{
|
||||
@@ -57,6 +63,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new SmtpProtocolException("protocol error"), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a service-not-connected exception is classified as transient.</summary>
|
||||
[Fact]
|
||||
public void Classify_ServiceNotConnectedException_IsTransient()
|
||||
{
|
||||
@@ -65,6 +72,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new ServiceNotConnectedException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a socket exception is classified as transient.</summary>
|
||||
[Fact]
|
||||
public void Classify_SocketException_IsTransient()
|
||||
{
|
||||
@@ -73,6 +81,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new SocketException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timeout exception is classified as transient.</summary>
|
||||
[Fact]
|
||||
public void Classify_TimeoutException_IsTransient()
|
||||
{
|
||||
@@ -81,6 +90,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new TimeoutException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a requested cancellation is classified as unknown.</summary>
|
||||
[Fact]
|
||||
public void Classify_RequestedCancellation_IsUnknown()
|
||||
{
|
||||
@@ -92,6 +102,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new OperationCanceledException(), cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an operation cancellation without a requested cancellation is classified as unknown.</summary>
|
||||
[Fact]
|
||||
public void Classify_OperationCanceledWithoutRequestedCancellation_IsUnknown()
|
||||
{
|
||||
@@ -101,6 +112,7 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new OperationCanceledException(), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unrecognised exception is classified as unknown.</summary>
|
||||
[Fact]
|
||||
public void Classify_UnrecognisedException_IsUnknown()
|
||||
{
|
||||
@@ -109,6 +121,9 @@ public class SmtpErrorClassifierTests
|
||||
SmtpErrorClassifier.Classify(new InvalidOperationException("bad credential triple"), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the transient check matches the classification of the status code.</summary>
|
||||
/// <param name="statusCode">The SMTP status code.</param>
|
||||
/// <param name="expectedTransient">Whether the status code is expected to be transient.</param>
|
||||
[Theory]
|
||||
[InlineData(450, true)]
|
||||
[InlineData(550, false)]
|
||||
@@ -121,6 +136,7 @@ public class SmtpErrorClassifierTests
|
||||
Assert.Equal(expectedTransient, SmtpErrorClassifier.IsTransient(ex, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a requested cancellation is not transient.</summary>
|
||||
[Fact]
|
||||
public void IsTransient_RequestedCancellation_IsFalse()
|
||||
{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// NS-005: Tests for parsing the configured SMTP TLS mode into the three-state enum.
|
||||
/// Tests for parsing the configured SMTP TLS mode into the three-state enum.
|
||||
/// </summary>
|
||||
public class SmtpTlsModeParserTests
|
||||
{
|
||||
/// <summary>Verifies that a known mode string parses to the expected TLS mode.</summary>
|
||||
/// <param name="input">The configured mode string.</param>
|
||||
/// <param name="expected">The expected parsed TLS mode.</param>
|
||||
[Theory]
|
||||
[InlineData("none", SmtpTlsMode.None)]
|
||||
[InlineData("None", SmtpTlsMode.None)]
|
||||
@@ -19,6 +22,8 @@ public class SmtpTlsModeParserTests
|
||||
Assert.Equal(expected, SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null or empty mode defaults to StartTLS.</summary>
|
||||
/// <param name="input">The null or empty mode string.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -28,13 +33,15 @@ public class SmtpTlsModeParserTests
|
||||
Assert.Equal(SmtpTlsMode.StartTls, SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown mode string throws.</summary>
|
||||
/// <param name="input">The unrecognized mode string.</param>
|
||||
[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.
|
||||
// An unknown mode must be rejected, not silently treated as Auto.
|
||||
Assert.Throws<ArgumentException>(() => SmtpTlsModeParser.Parse(input));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user