From de0e767930638333c08016cc0f681cc2e1282026 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:38:53 -0300 Subject: [PATCH] fix(auth): refuse a reset email when the queue is full instead of dropping it The channel used DropWrite, under which TryWrite reports success and discards the email, so a full queue still counted the request and never sent the code. Wait makes TryWrite return false when the queue is full, without blocking, so the request is released and the user can ask again. --- .../PasswordResetEmailChannelTests.cs | 43 +++++++++++++++++++ .../PasswordResetEmailDelivery.cs | 5 ++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs diff --git a/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs b/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs new file mode 100644 index 0000000..5366173 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/PasswordResetEmailChannelTests.cs @@ -0,0 +1,43 @@ +using Api.SeaHavenIndustries.HostedServices; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class PasswordResetEmailChannelTests +{ + [Fact] + public void A_full_queue_refuses_the_email_instead_of_dropping_it_silently() + { + var channel = new PasswordResetEmailChannel(NullLogger.Instance); + for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++) + channel.TryEnqueue($"user{i}@example.com", "subject", "body").Should().BeTrue(); + + var accepted = channel.TryEnqueue("late@example.com", "subject", "body"); + + accepted.Should().BeFalse(); + channel.Pending.Should().Be(PasswordResetEmailChannel.Capacity); + } + + [Fact] + public async Task A_refused_email_is_never_delivered_and_the_queue_accepts_again_once_drained() + { + var channel = new PasswordResetEmailChannel(NullLogger.Instance); + for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++) + channel.TryEnqueue($"user{i}@example.com", "subject", "body"); + channel.TryEnqueue("late@example.com", "subject", "body").Should().BeFalse(); + + var delivered = new List(); + while (channel.Reader.TryRead(out var email)) + { + delivered.Add(email.EmailTo); + channel.MarkHandled(); + } + + delivered.Should().HaveCount(PasswordResetEmailChannel.Capacity).And.NotContain("late@example.com"); + channel.Pending.Should().Be(0); + channel.TryEnqueue("retry@example.com", "subject", "body").Should().BeTrue(); + (await channel.Reader.ReadAsync()).EmailTo.Should().Be("retry@example.com"); + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs index 5b2b7af..da87fdd 100644 --- a/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs +++ b/Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs @@ -29,7 +29,10 @@ namespace Api.SeaHavenIndustries.HostedServices private readonly Channel _channel = Channel.CreateBounded( new BoundedChannelOptions(Capacity) { - FullMode = BoundedChannelFullMode.DropWrite, + // Wait, not DropWrite: with DropWrite, TryWrite reports success and discards + // the email, so a full queue would still count the request. TryWrite never + // blocks; under Wait it returns false when the queue is full. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true }); private readonly ILogger _logger;