fix(auth): trace a reset email the provider rejects as an error

A send that the mail provider did not accept finished its background
transaction as ok, so rejected reset emails looked delivered in tracing.
It now finishes as an error with a fixed message that names no recipient.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 20:01:58 -03:00
parent c985e9423d
commit 74d5aa17eb
2 changed files with 66 additions and 5 deletions

View file

@ -0,0 +1,55 @@
using Api.SeaHavenIndustries.HostedServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Sentry;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class PasswordResetEmailSenderTracingTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Each_send_finishes_its_transaction_as_ok_only_when_the_provider_accepts_it(bool accepted)
{
var transaction = new Mock<ITransactionTracer>();
var hub = new Mock<IHub>();
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
hub.Setup(h => h.StartTransaction(It.IsAny<ITransactionContext>(), It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var sender = new Mock<IEmailSender>();
sender.Setup(s => s.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(accepted);
var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider();
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
var worker = new PasswordResetEmailSenderHostedService(
channel,
services.GetRequiredService<IServiceScopeFactory>(),
NullLogger<PasswordResetEmailSenderHostedService>.Instance,
hub.Object);
await worker.StartAsync(CancellationToken.None);
Assert.True(channel.TryEnqueue("user@example.com", "subject", "Your code is 123456"));
var deadline = DateTime.UtcNow.AddSeconds(10);
while (channel.Pending > 0 && DateTime.UtcNow < deadline)
await Task.Delay(10);
await worker.StopAsync(CancellationToken.None);
Assert.Equal(0, channel.Pending);
if (accepted)
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
transaction.Verify(t => t.Finish(It.IsAny<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
}
else
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never);
transaction.Verify(t => t.Finish(
It.Is<Exception>(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")),
SpanStatus.InternalError), Times.Once);
}
}
}

View file

@ -91,8 +91,10 @@ namespace Api.SeaHavenIndustries.HostedServices
$"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}"); $"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}");
try try
{ {
await SendAsync(email); if (await SendAsync(email))
transaction.FinishOk(); transaction.FinishOk();
else
transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email."));
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@ -112,12 +114,16 @@ namespace Api.SeaHavenIndustries.HostedServices
} }
} }
private async Task SendAsync(PasswordResetEmail email) /// <summary>True when the mail provider accepted the email.</summary>
private async Task<bool> SendAsync(PasswordResetEmail email)
{ {
await using var scope = _scopeFactory.CreateAsyncScope(); await using var scope = _scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>(); var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
if (!await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body)) if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
return true;
_logger.LogWarning("Password reset email was not accepted by the mail provider."); _logger.LogWarning("Password reset email was not accepted by the mail provider.");
return false;
} }
} }
} }