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:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent bf6bd8de5a
commit f5199e9da9
6 changed files with 454 additions and 41 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-17 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 5 |
| Open findings | 0 |
## Summary
@@ -497,7 +497,7 @@ Module test suite is green at 56 tests.
|--|--|
| Severity | High |
| Category | Error handling & resilience |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:214-228`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:308-312`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:56-84` |
**Description**
@@ -510,7 +510,7 @@ Add a catch-all to `DeliverBufferedAsync` for exceptions that `ClassifySmtpError
**Resolution**
_Unresolved._
Resolved 2026-05-17. Root cause confirmed against source — `DeliverBufferedAsync` caught only `SmtpPermanentException`, so an OAuth2 token-fetch `HttpRequestException`/`InvalidOperationException` escaped the handler and the S&F engine reinterpreted any throw as transient. Added a final `catch (Exception ex)` to `DeliverBufferedAsync` that decides deliberately: an `HttpRequestException` with a 5xx token-endpoint status re-throws (transient, retry); every other unclassified cause (a 401/4xx token rejection, a malformed-credential `InvalidOperationException`) returns `false` so the message parks immediately. Caller-cancellation and typed transient SMTP errors are re-thrown via dedicated filters above it. Tests `DeliverBuffered_OAuth2MalformedCredentials_ReturnsFalseSoMessageParks`, `DeliverBuffered_OAuth2TokenEndpoint401_ReturnsFalseSoMessageParks`, `DeliverBuffered_OAuth2TokenEndpoint503_ThrowsSoEngineRetries`.
### NotificationService-015 — Unclassified exceptions (OAuth2 token fetch, non-cancellation OCE) escape `SendAsync` to the calling script
@@ -518,7 +518,7 @@ _Unresolved._
|--|--|
| Severity | High |
| Category | Error handling & resilience |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:96-148`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:308-312` |
**Description**
@@ -531,7 +531,7 @@ Add a final `catch (Exception ex)` to `SendAsync` that converts any otherwise-un
**Resolution**
_Unresolved._
Resolved 2026-05-17. Root cause confirmed — `SendAsync` had only three catch clauses and an `Unknown`-classified exception (OAuth2 `HttpRequestException`/`InvalidOperationException`) fell through all of them and escaped to the calling script. Added a final `catch (Exception ex)` to `SendAsync` that converts any otherwise-unhandled exception into a credential-scrubbed `NotificationResult(false, "Notification delivery failed: ...")` and logs it; caller-requested cancellation is still re-thrown by the filter above so it never reaches the catch-all. The obsolete NS-003 test that asserted such an exception escapes was re-triaged to assert the clean result instead. Tests `Send_OAuth2TokenFetchFails_ReturnsCleanError_DoesNotThrow`, `Send_OAuth2MalformedCredentials_ReturnsCleanError_DoesNotThrow`, `Send_UnclassifiedException_RedactsCredentialFromResult`.
### NotificationService-016 — `AuthenticateAsync` silently sends unauthenticated for an unknown auth type or empty credentials
@@ -539,7 +539,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:46-67` |
**Description**
@@ -552,7 +552,7 @@ Make missing/unparseable credentials and an unrecognised `AuthType` hard errors:
**Resolution**
_Unresolved._
Resolved 2026-05-17. Root cause confirmed — `AuthenticateAsync` returned silently for null/empty credentials, had no `default:` arm, and skipped a "basic" credential that did not split into two parts, so the connection sent mail unauthenticated. All three now throw `SmtpPermanentException` (a permanent configuration fault); because the exception is permanent, `SendAsync` returns a clean `NotificationResult` failure and `DeliverBufferedAsync` parks the buffered message — no unauthenticated send is ever attempted. Tests `Authenticate_EmptyCredentials_Throws`, `Authenticate_UnknownAuthType_Throws`, `Authenticate_BasicCredentialWithoutColon_Throws` in the new `MailKitSmtpClientWrapperTests`.
### NotificationService-017 — `NotificationOptions` is bound from configuration but never read (dead config)
@@ -560,7 +560,7 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationOptions.cs:1-15`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:10-11`, `src/ScadaLink.Host/SiteServiceRegistration.cs:70` |
**Description**
@@ -573,7 +573,7 @@ Either delete `NotificationOptions` and both of its registrations, or genuinely
**Resolution**
_Unresolved._
Resolved 2026-05-17. Root cause confirmed — `NotificationOptions` was bound but never read. Implemented the documented-fallback intent rather than deleting it: `NotificationDeliveryService` now takes an optional `IOptions<NotificationOptions>` and uses its `ConnectionTimeoutSeconds`/`MaxConcurrentConnections` whenever the deployed `SmtpConfiguration` field is non-positive (a value on the row still wins). The misleading XML doc on `NotificationOptions` was corrected to describe the precedence accurately. The duplicate `services.Configure<NotificationOptions>` in `Host/SiteServiceRegistration.cs:70` is harmless (DI keeps a single bound instance) and lives outside this module's edit scope, so it was left in place. Tests `Send_SmtpConfigTimeoutUnset_FallsBackToNotificationOptions`, `Send_SmtpConfigTimeoutSet_OverridesNotificationOptions`.
### NotificationService-018 — Concurrency limiter: lock-free read of a non-volatile field, never resized on redeployment, never disposed
@@ -581,7 +581,7 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:237-255` |
**Description**
@@ -594,4 +594,4 @@ Replace the hand-rolled double-checked init with `Lazy<SemaphoreSlim>` or `LazyI
**Resolution**
_Unresolved._
Resolved 2026-05-17. All three issues confirmed against source. The hand-rolled double-checked init was replaced with a `Lazy<SemaphoreSlim>` — its publication is correctly synchronised, eliminating the lock-free read of a non-`volatile` reference. `NotificationDeliveryService` now implements `IDisposable` and disposes the limiter (if created) under the existing lock, with idempotent re-entry and an `ObjectDisposedException` guard in `SendAsync`/`GetConcurrencyLimiter`; the scoped DI registration disposes it per scope. The limiter remains scoped (not hoisted to a site singleton) — the design doc deploys one SMTP config per site and the per-instance capture is bounded; the redeploy-resize concern is acknowledged as low-impact and not changed here, since hoisting would require a registration change for marginal benefit. Tests `Service_Dispose_DisposesConcurrencyLimiter` plus the existing `Send_MaxConcurrentConnections_LimitsConcurrentDeliveries`.

View File

@@ -45,17 +45,30 @@ public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
public async Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
{
// NS-016: missing/unparseable credentials and an unrecognised auth type used
// to make this method silently return and the connection then sent mail
// unauthenticated — masking a misconfiguration against an open relay and, at
// worst, sending where authentication was required. Authentication being
// skipped must never be silent: each of these is a permanent configuration
// fault, surfaced as SmtpPermanentException so SendAsync returns a clean
// failure and DeliverBufferedAsync parks the buffered message.
if (string.IsNullOrEmpty(credentials))
return;
{
throw new SmtpPermanentException(
$"SMTP auth type '{authType}' requires credentials, but none are configured.");
}
switch (authType.ToLowerInvariant())
{
case "basic":
var parts = credentials.Split(':', 2);
if (parts.Length == 2)
if (parts.Length != 2)
{
await _client.AuthenticateAsync(parts[0], parts[1], cancellationToken);
throw new SmtpPermanentException(
"Basic SMTP credentials must be in 'username:password' form.");
}
await _client.AuthenticateAsync(parts[0], parts[1], cancellationToken);
break;
case "oauth2":
@@ -63,6 +76,10 @@ public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
var oauth2 = new SaslMechanismOAuth2("", credentials);
await _client.AuthenticateAsync(oauth2, cancellationToken);
break;
default:
throw new SmtpPermanentException(
$"Unsupported SMTP auth type '{authType}'. Expected one of: basic, oauth2.");
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using MailKit;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
@@ -18,26 +19,31 @@ namespace ScadaLink.NotificationService;
/// Transient: connection refused, timeout, SMTP 4xx → hand to S&amp;F.
/// Permanent: SMTP 5xx → returned to script.
/// </summary>
public class NotificationDeliveryService : INotificationDeliveryService
public class NotificationDeliveryService : INotificationDeliveryService, IDisposable
{
private readonly INotificationRepository _repository;
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
private readonly OAuth2TokenService? _tokenService;
private readonly StoreAndForwardService? _storeAndForward;
private readonly ILogger<NotificationDeliveryService> _logger;
private readonly NotificationOptions _options;
public NotificationDeliveryService(
INotificationRepository repository,
Func<ISmtpClientWrapper> smtpClientFactory,
ILogger<NotificationDeliveryService> logger,
OAuth2TokenService? tokenService = null,
StoreAndForwardService? storeAndForward = null)
StoreAndForwardService? storeAndForward = null,
IOptions<NotificationOptions>? options = null)
{
_repository = repository;
_smtpClientFactory = smtpClientFactory;
_logger = logger;
_tokenService = tokenService;
_storeAndForward = storeAndForward;
// NS-017: NotificationOptions supplies the documented fallback values used
// when a deployed SmtpConfiguration row leaves a field unset (non-positive).
_options = options?.Value ?? new NotificationOptions();
}
/// <summary>
@@ -50,6 +56,8 @@ public class NotificationDeliveryService : INotificationDeliveryService
string? originInstanceName = null,
CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var list = await _repository.GetListByNameAsync(listName, cancellationToken);
if (list == null)
{
@@ -146,6 +154,24 @@ public class NotificationDeliveryService : INotificationDeliveryService
return new NotificationResult(true, null, WasBuffered: true);
}
catch (Exception ex)
{
// NS-015: a failure that ClassifySmtpError does not recognise (Unknown) —
// most importantly an OAuth2 token-fetch failure (HttpRequestException
// from EnsureSuccessStatusCode, or InvalidOperationException from a
// malformed credential triple) — used to fall through all the catch
// clauses above and escape SendAsync as a raw exception to the calling
// script, which the INotificationDeliveryService contract never
// advertises. Convert any otherwise-unhandled exception into a clean,
// credential-scrubbed permanent NotificationResult: returning control to
// the script is the safe default. (A caller-requested cancellation is
// already re-thrown by the filter above and never reaches here.)
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
_logger.LogError(
"Unclassified failure sending to list {List} ({ExceptionType}): {Detail}",
listName, ex.GetType().Name, detail);
return new NotificationResult(false, $"Notification delivery failed: {detail}");
}
}
/// <summary>
@@ -224,36 +250,103 @@ public class NotificationDeliveryService : INotificationDeliveryService
payload.ListName, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
return false;
}
// Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries.
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A handler shutdown cancellation is neither a delivery success nor a
// permanent failure — let it propagate so the engine does not park.
throw;
}
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
{
// A typed transient SMTP error: re-throw so the S&F engine retries.
throw;
}
catch (Exception ex)
{
// NS-014: an exception ClassifySmtpError does not recognise (Unknown) —
// chiefly an OAuth2 token-fetch failure — used to escape this handler.
// The S&F engine treats ANY thrown exception as transient, so a
// permanently-broken config (bad client secret, malformed credential
// triple) was retried on every sweep until MaxRetries, burning token
// endpoint calls. Decide deliberately rather than letting it leak:
// - an HttpRequestException with a 5xx token-endpoint status is a
// transient outage → re-throw so the engine retries;
// - everything else (a 4xx/401 token rejection, a malformed credential
// InvalidOperationException, any other unclassified fault) is not
// fixable by retrying → return false so the message is parked.
if (ex is HttpRequestException { StatusCode: { } status } && (int)status is >= 500 and < 600)
{
_logger.LogWarning(
"Buffered notification to list '{List}' hit a transient OAuth2 token-endpoint error ({Status}); will retry.",
payload.ListName, (int)status);
throw;
}
_logger.LogError(
"Buffered notification to list '{List}' failed with a non-retryable error ({ExceptionType}: {Detail}); parking.",
payload.ListName, ex.GetType().Name,
CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
return false;
}
}
private sealed record BufferedNotification(string ListName, string Subject, string Message);
/// <summary>
/// NS-007: throttles concurrent SMTP deliveries to the configured
/// <c>MaxConcurrentConnections</c>. Created lazily from the first SMTP config
/// seen (one SMTP config is deployed per site, so the limit is stable).
/// <c>MaxConcurrentConnections</c>. One SMTP config is deployed per site, so the
/// limit is a stable per-site invariant; it is captured lazily on first use.
/// NS-018: a <see cref="Lazy{T}"/> replaces the hand-rolled double-checked
/// init — its publication is correctly synchronised (no lock-free read of a
/// non-volatile field) and it is disposed in <see cref="Dispose"/>.
/// </summary>
private SemaphoreSlim? _concurrencyLimiter;
private Lazy<SemaphoreSlim>? _concurrencyLimiter;
private readonly object _limiterLock = new();
private bool _disposed;
private SemaphoreSlim GetConcurrencyLimiter(SmtpConfiguration config)
{
if (_concurrencyLimiter != null)
{
return _concurrencyLimiter;
}
// NS-018: the limiter is sized once; capture the size now so the Lazy
// factory does not close over a value that could change between calls.
var configured = config.MaxConcurrentConnections > 0
? config.MaxConcurrentConnections
// NS-017: fall back to the NotificationOptions value, then the
// design-doc default of 5, when the deployed row leaves it unset.
: _options.MaxConcurrentConnections > 0 ? _options.MaxConcurrentConnections : 5;
lock (_limiterLock)
{
// NS-007: a non-positive configured value would make SemaphoreSlim
// throw; fall back to the design-doc default of 5.
var max = config.MaxConcurrentConnections > 0 ? config.MaxConcurrentConnections : 5;
_concurrencyLimiter ??= new SemaphoreSlim(max, max);
return _concurrencyLimiter;
ObjectDisposedException.ThrowIf(_disposed, this);
_concurrencyLimiter ??= new Lazy<SemaphoreSlim>(
() => new SemaphoreSlim(configured, configured));
return _concurrencyLimiter.Value;
}
}
/// <summary>
/// NS-018: disposes the lazily-created concurrency limiter. The service is a
/// scoped DI service; without this the <see cref="SemaphoreSlim"/> leaked a
/// handle per scope.
/// </summary>
public void Dispose()
{
lock (_limiterLock)
{
if (_disposed)
{
return;
}
_disposed = true;
if (_concurrencyLimiter is { IsValueCreated: true } limiter)
{
limiter.Value.Dispose();
}
}
GC.SuppressFinalize(this);
}
/// <summary>
/// NS-008: Validates the sender and recipient email addresses, returning a
/// human-readable error string if any is malformed, or null if all parse.
@@ -300,8 +393,13 @@ public class NotificationDeliveryService : INotificationDeliveryService
try
{
// NS-005/NS-007: explicit TLS mode and the configured connection timeout.
// NS-017: when the deployed SmtpConfiguration row leaves the timeout
// unset (non-positive), fall back to the NotificationOptions value.
var timeoutSeconds = config.ConnectionTimeoutSeconds > 0
? config.ConnectionTimeoutSeconds
: _options.ConnectionTimeoutSeconds;
await smtp.ConnectAsync(
config.Host, config.Port, tlsMode, config.ConnectionTimeoutSeconds, cancellationToken);
config.Host, config.Port, tlsMode, timeoutSeconds, cancellationToken);
// Resolve credentials (OAuth2 token fetched/cached by the token service).
var credentials = config.Credentials;

View File

@@ -1,15 +1,26 @@
namespace ScadaLink.NotificationService;
/// <summary>
/// Configuration options for the Notification Service.
/// Most SMTP configuration is stored in the database (SmtpConfiguration entity).
/// This provides fallback defaults and operational limits.
/// Configuration options for the Notification Service, bound from the
/// <c>ScadaLink:Notification</c> configuration section.
///
/// SMTP settings are primarily carried by the deployed <c>SmtpConfiguration</c>
/// entity. NS-017: these values are the fallback used by
/// <see cref="NotificationDeliveryService"/> when the corresponding
/// <c>SmtpConfiguration</c> field is left unset (non-positive) on a partially
/// deployed row — a value present on the row always takes precedence.
/// </summary>
public class NotificationOptions
{
/// <summary>Default connection timeout for SMTP connections.</summary>
/// <summary>
/// Connection timeout (seconds) used when <c>SmtpConfiguration.ConnectionTimeoutSeconds</c>
/// is unset. Default 30s.
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
/// <summary>Maximum concurrent SMTP connections.</summary>
/// <summary>
/// Maximum concurrent SMTP connections used when
/// <c>SmtpConfiguration.MaxConcurrentConnections</c> is unset. Default 5.
/// </summary>
public int MaxConcurrentConnections { get; set; } = 5;
}

View File

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

View File

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