diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index e64014f..045acea 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -151,7 +151,7 @@ public class AuthenticationServiceTests } [Fact] - public async Task ForgetPassword_EmailThatCannotBeQueued_DropsTheCodeAndDoesNotCountTheRequest() + public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest() { var user = IdentityTestHelpers.User(); var userData = new Mock(); @@ -175,19 +175,38 @@ public class AuthenticationServiceTests for (var request = 0; request < 4; request++) await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); - // The three undelivered requests did not use up the hourly limit of three. + // The three undelivered requests did not use up the hourly limit of three, and every + // email carries the code stored just before it was queued. email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny()), Times.Exactly(4)); stored.Should().HaveCount(4); - for (var request = 0; request < 3; request++) + for (var request = 0; request < 4; request++) { var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; - PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeFalse(); + PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeTrue(); } - var delivered = System.Text.RegularExpressions.Regex.Match(bodies[3], @"Your Password Reset Code is: (\d{6})").Groups[1].Value; - PasswordResetCodeSecrets.Matches(ResetKey, stored[3].Salt, delivered, stored[3].Hash).Should().BeTrue(); forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForgetPassword_WriteThatFailsOrIsCancelled_QueuesNoEmail(bool cancelled) + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + forget.Setup(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))); + var email = new Mock(); + email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); + + var act = () => NewService(userData, forget, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + await act.Should().ThrowAsync(); + email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail() { diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index fbc7a2e..1b982d0 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -170,12 +170,22 @@ namespace SeaHaven.Services.Implementation var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); - // The request stays counted only once its row is stored. A code that cannot be - // emailed is stored unmatchable and not counted, and a failed or cancelled - // write is not counted either. + // The email is queued only after its code is stored, so a failed or cancelled + // write never sends a code that cannot be used. The request stays counted only + // when the row is stored and, for an account, its email was queued; when the + // queue is full the stored code is left unsent and the user can ask again. var counted = false; try { + await _forgetPasswordDataService.ReplaceCodeAsync( + active ? user!.Email! : requested, + active ? user!.Id : string.Empty, + active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), + salt, + nowUtc.Add(ResetCodeLifetime), + nowUtc, + cancellationToken); + var queued = false; if (active) { @@ -183,14 +193,6 @@ namespace SeaHaven.Services.Implementation queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); } - await _forgetPasswordDataService.ReplaceCodeAsync( - active ? user!.Email! : requested, - active ? user!.Id : string.Empty, - queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), - salt, - nowUtc.Add(ResetCodeLifetime), - nowUtc, - cancellationToken); counted = queued || !active; } finally