fix(notification-service): resolve NotificationService-014..018 — classify OAuth2 failures, fail on bad auth config, wire NotificationOptions fallback, disposable concurrency limiter
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
namespace ScadaLink.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.
|
||||
/// </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"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
@@ -326,23 +327,25 @@ public class NotificationDeliveryServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_NonSmtpExceptionWith5xxLookalikeText_NotPromotedToPermanent()
|
||||
public async Task Send_NonSmtpExceptionWith5xxLookalikeText_NotClassifiedAsPermanentSmtpError()
|
||||
{
|
||||
// NS-003: the old classifier promoted ANY exception whose message contained
|
||||
// "5." / "550" / etc. to a permanent SMTP error — so an unrelated failure
|
||||
// referencing a host like "smtp5.example.com" was silently swallowed as a
|
||||
// clean permanent NotificationResult. Classification now uses MailKit's
|
||||
// clean "Permanent SMTP error" result. Classification now uses MailKit's
|
||||
// typed exceptions only, so a non-SMTP exception is no longer misclassified.
|
||||
// NS-015: that unclassified exception no longer escapes SendAsync either — it
|
||||
// returns a clean generic "delivery failed" result (NOT "Permanent SMTP error").
|
||||
SetupHappyPath();
|
||||
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new InvalidOperationException("internal error talking to smtp5.example.com"));
|
||||
|
||||
var service = CreateService();
|
||||
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
// The exception is not classified at all (not a typed SMTP failure); it
|
||||
// surfaces rather than being mistaken for a permanent 5xx rejection.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SendAsync("ops-team", "Alert", "Body"));
|
||||
Assert.False(result.Success);
|
||||
// It is reported as a generic delivery failure, not mistaken for a 5xx rejection.
|
||||
Assert.DoesNotContain("Permanent SMTP error", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -788,4 +791,243 @@ public class NotificationDeliveryServiceTests
|
||||
Assert.Equal("oauth2", recording.AuthType);
|
||||
Assert.Equal("oauth2-access-token-xyz", recording.Credentials);
|
||||
}
|
||||
|
||||
// ── NotificationService-015: unclassified exceptions must not escape SendAsync ──
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="OAuth2TokenService"/> whose token endpoint returns a non-success
|
||||
/// HTTP status, so <c>GetTokenAsync</c> throws <see cref="HttpRequestException"/>.
|
||||
/// </summary>
|
||||
private static OAuth2TokenService CreateFailingTokenService(
|
||||
HttpStatusCode status = HttpStatusCode.Unauthorized)
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
factory.CreateClient(Arg.Any<string>())
|
||||
.Returns(_ => new HttpClient(new FailingHttpHandler(status)));
|
||||
return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||
}
|
||||
|
||||
private sealed class FailingHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly System.Net.HttpStatusCode _status;
|
||||
public FailingHttpHandler(System.Net.HttpStatusCode status) => _status = status;
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent("error")
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_OAuth2TokenFetchFails_ReturnsCleanError_DoesNotThrow()
|
||||
{
|
||||
// NS-015: an OAuth2 token-fetch failure (HttpRequestException from
|
||||
// EnsureSuccessStatusCode) is classified Unknown — it fell through all three
|
||||
// catch clauses and escaped SendAsync as a raw exception to the calling
|
||||
// script. It must instead produce a clean NotificationResult failure.
|
||||
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() => new RecordingAuthClient(),
|
||||
NullLogger<NotificationDeliveryService>.Instance,
|
||||
tokenService: CreateFailingTokenService());
|
||||
|
||||
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_OAuth2MalformedCredentials_ReturnsCleanError_DoesNotThrow()
|
||||
{
|
||||
// NS-015: a malformed tenant:client:secret triple makes GetTokenAsync throw
|
||||
// InvalidOperationException — also Unknown-classified, also escaped SendAsync.
|
||||
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() => new RecordingAuthClient(),
|
||||
NullLogger<NotificationDeliveryService>.Instance,
|
||||
tokenService: CreateTokenService("unused"));
|
||||
|
||||
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_UnclassifiedException_RedactsCredentialFromResult()
|
||||
{
|
||||
// NS-015: the catch-all result, like the permanent-error path (NS-009), must
|
||||
// not leak credential fragments echoed in an exception message.
|
||||
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 InvalidOperationException("internal failure exposing Hunter2Secret"));
|
||||
|
||||
var service = CreateService();
|
||||
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── NotificationService-014: unclassified exceptions must not escape DeliverBufferedAsync ──
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_OAuth2MalformedCredentials_ReturnsFalseSoMessageParks()
|
||||
{
|
||||
// NS-014: DeliverBufferedAsync caught only SmtpPermanentException. An OAuth2
|
||||
// InvalidOperationException (a permanent, unfixable misconfiguration) escaped
|
||||
// the handler; the S&F engine reinterprets any thrown exception as transient
|
||||
// and retries forever. A non-retryable cause must park (return false).
|
||||
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() => new RecordingAuthClient(),
|
||||
NullLogger<NotificationDeliveryService>.Instance,
|
||||
tokenService: CreateTokenService("unused"));
|
||||
|
||||
var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));
|
||||
|
||||
Assert.False(delivered); // parked — retrying cannot fix a malformed credential
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_OAuth2TokenEndpoint401_ReturnsFalseSoMessageParks()
|
||||
{
|
||||
// NS-014: a 401 from the OAuth2 token endpoint is a permanent credential
|
||||
// failure — it must park, not be retried on every sweep.
|
||||
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() => new RecordingAuthClient(),
|
||||
NullLogger<NotificationDeliveryService>.Instance,
|
||||
tokenService: CreateFailingTokenService(HttpStatusCode.Unauthorized));
|
||||
|
||||
var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));
|
||||
|
||||
Assert.False(delivered); // parked — a 401 is a permanent credential failure
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_OAuth2TokenEndpoint503_ThrowsSoEngineRetries()
|
||||
{
|
||||
// NS-014: a 5xx from the OAuth2 token endpoint is a transient outage — the
|
||||
// handler must throw so the S&F engine retries on the next sweep.
|
||||
var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository,
|
||||
() => new RecordingAuthClient(),
|
||||
NullLogger<NotificationDeliveryService>.Instance,
|
||||
tokenService: CreateFailingTokenService(HttpStatusCode.ServiceUnavailable));
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(
|
||||
() => service.DeliverBufferedAsync(BufferedNotification("ops-team")));
|
||||
}
|
||||
|
||||
// ── NotificationService-017: NotificationOptions used as fallback for unset SmtpConfiguration fields ──
|
||||
|
||||
[Fact]
|
||||
public async Task Send_SmtpConfigTimeoutUnset_FallsBackToNotificationOptions()
|
||||
{
|
||||
// NS-017: NotificationOptions was bound from configuration but never read.
|
||||
// It is now the documented fallback: when SmtpConfiguration.ConnectionTimeoutSeconds
|
||||
// is non-positive (0 from a partially-deployed row) the NotificationOptions
|
||||
// value is used instead of leaving the timeout unbounded.
|
||||
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
||||
ConnectionTimeoutSeconds = 0 // not configured on the row
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
var recording = new RecordingTlsClient();
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(
|
||||
new NotificationOptions { ConnectionTimeoutSeconds = 42, MaxConcurrentConnections = 5 });
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
|
||||
options: options);
|
||||
|
||||
await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.Equal(42, recording.ConnectionTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_SmtpConfigTimeoutSet_OverridesNotificationOptions()
|
||||
{
|
||||
// NS-017: a value present on the SmtpConfiguration row still wins over the
|
||||
// NotificationOptions fallback.
|
||||
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||
{
|
||||
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
||||
ConnectionTimeoutSeconds = 19
|
||||
};
|
||||
SetupHappyPathWithSmtp(cfg);
|
||||
var recording = new RecordingTlsClient();
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(
|
||||
new NotificationOptions { ConnectionTimeoutSeconds = 42 });
|
||||
var service = new NotificationDeliveryService(
|
||||
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
|
||||
options: options);
|
||||
|
||||
await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.Equal(19, recording.ConnectionTimeoutSeconds);
|
||||
}
|
||||
|
||||
// ── NotificationService-018: concurrency limiter disposal ──
|
||||
|
||||
[Fact]
|
||||
public async Task Service_Dispose_DisposesConcurrencyLimiter()
|
||||
{
|
||||
// NS-018: the lazily-created SemaphoreSlim was never disposed and the service
|
||||
// did not implement IDisposable — a slow handle leak per scope. Disposing the
|
||||
// service must dispose the limiter; using it afterwards must fault.
|
||||
SetupHappyPath();
|
||||
var service = CreateService();
|
||||
|
||||
// A send creates the limiter.
|
||||
await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.IsAssignableFrom<IDisposable>(service);
|
||||
((IDisposable)service).Dispose();
|
||||
|
||||
// A second send after disposal must fail fast on the disposed semaphore
|
||||
// rather than silently using a disposed object.
|
||||
await Assert.ThrowsAnyAsync<ObjectDisposedException>(
|
||||
() => service.SendAsync("ops-team", "Alert", "Body"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user