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

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