using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class AuthenticationServiceTests { private static AuthenticationService NewService( Mock userData, Mock forget, Mock email, out Mock> store, out Mock> hasher) { var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); store = s; hasher = h; return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System); } private static JwtOptions JwtOptions => new() { Secret = new string('x', 64), ValidIssuer = "issuer", ValidAudience = "audience" }; [Fact] public async Task Login_UnknownUser_ReturnsNull() { var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); var result = await service.LoginAsync("nobody", "pw", CancellationToken.None); result.Should().BeNull(); } [Fact] public async Task Login_DeletedUser_RejectedBeforePasswordCheck() { var deletedUser = IdentityTestHelpers.User(isDeleted: true); var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); var result = await service.LoginAsync("alice", "pw", CancellationToken.None); result.Should().BeNull(); hasher.Verify(h => h.VerifyHashedPassword(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() { var user = IdentityTestHelpers.User(); var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); store.As>() .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success); var result = await service.LoginAsync("alice", "pw", CancellationToken.None); result.Should().NotBeNull(); result!.Id.Should().Be(user.Id); result.Email.Should().Be(user.Email); result.PhoneNumber.Should().Be(user.PhoneNumber); result.Fullname.Should().Be("Alice Q"); result.UserRole.Should().Be("Admin"); result.Token.Should().NotBeNullOrEmpty(); result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9)); } [Fact] public async Task Login_BadPassword_ReturnsNull() { var user = IdentityTestHelpers.User(); var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); store.As>() .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed); var result = await service.LoginAsync("alice", "wrong", CancellationToken.None); result.Should().BeNull(); } private static byte[] ResetKey => PasswordResetCodeSecrets.DeriveKey(JwtOptions.Secret); [Fact] public async Task ForgetPassword_RegisteredEmail_StoresOnlyAKeyedHashAndQueuesTheCode() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); var forget = new Mock(); var email = new Mock(); string? stored = null, salt = null, body = null; DateTime expires = default; forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; }) .Returns(Task.CompletedTask); email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny())) .Callback((_, _, b) => body = b) .Returns(true); var service = NewService(userData, forget, email, out _, out _); var before = DateTime.UtcNow; await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value; code.Should().HaveLength(6); stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$"); PasswordResetCodeSecrets.Matches(ResetKey, salt!, code, stored!).Should().BeTrue(); expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5)); } [Fact] public async Task ForgetPassword_UnknownEmail_MakesTheSameDataCallsAndQueuesNothing() { var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); var registered = new Mock(); var unregistered = new Mock(); var email = new Mock(); email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None); registered.Invocations.Select(call => call.Method.Name) .Should().Equal(unregistered.Invocations.Select(call => call.Method.Name)) .And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync)); unregistered.Verify(f => f.ReplaceCodeAsync("nope@example.com", string.Empty, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task ForgetPassword_EmailThatCannotBeQueued_DropsTheCodeAndDoesNotCountTheRequest() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); var forget = new Mock(); var stored = new List<(string Hash, string Salt)>(); forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, _, h, s, _, _, _) => stored.Add((h, s))) .Returns(Task.CompletedTask); var email = new Mock(); var bodies = new List(); // The queue is full for the first three requests. email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny(), It.IsAny())) .Returns((_, _, b) => { bodies.Add(b); return bodies.Count > 3; }); var service = NewService(userData, forget, email, out _, out _); 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. 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++) { 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(); } 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); } [Fact] public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail() { var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(IdentityTestHelpers.User()); var registered = new Mock(); var unregistered = new Mock(); var email = new Mock(); email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None); await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None); registered.Invocations.Select(call => call.Method.Name) .Should().Equal(unregistered.Invocations.Select(call => call.Method.Name)) .And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync)); } [Fact] public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered() { var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(IdentityTestHelpers.User(isDeleted: true)); var forget = new Mock(); var email = new Mock(); var service = NewService(userData, forget, email, out _, out _); await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); email.Verify(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData(null, "123456")] [InlineData("", "123456")] [InlineData(" ", "123456")] [InlineData("a@b.com", null)] [InlineData("a@b.com", "")] public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code) { var forget = new Mock(MockBehavior.Strict); var service = NewService(new Mock(), forget, new Mock(), out _, out _); var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None); result.Should().BeFalse(); } [Fact] public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset() { var forget = new Mock(); forget.Setup(f => f.GetByEmailAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ForgetPasswordCode?)null); var service = NewService(new Mock(), forget, new Mock(), out var store, out _); var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None); result.Should().BeFalse(); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails() { var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456"), FailedAttempts = 5 }; var forget = new Mock(); forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(false); var service = NewService(new Mock(), forget, new Mock(), out var store, out _); var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None); result.Should().BeFalse(); forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny()), Times.Once); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData(false)] [InlineData(true)] public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled) { var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") }; var forget = new Mock(); var lookups = 0; forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())) .Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) : Task.FromResult(pending)); forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())).ReturnsAsync(true); var service = NewService(new Mock(), forget, new Mock(), out _, out _); for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) { var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); await act.Should().ThrowAsync(); } (await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue(); } [Theory] [InlineData(false)] [InlineData(true)] public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled) { var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") }; var forget = new Mock(); forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny())).ReturnsAsync(pending); var consumes = 0; forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny(), It.IsAny())) .Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) : Task.FromResult(true)); var service = NewService(new Mock(), forget, new Mock(), out _, out _); for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++) { var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None); await act.Should().ThrowAsync(); } (await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue(); } [Theory] [InlineData(false)] [InlineData(true)] public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(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(); var writes = 0; forget.Setup(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour ? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")) : Task.CompletedTask); var email = new Mock(); email.Setup(e => e.TryEnqueue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); var service = NewService(userData, forget, email, out _, out _); for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++) { var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); await act.Should().ThrowAsync(); } await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1); forget.Verify(f => f.PurgeExpiredAsync(It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData("123456", "123456", true)] [InlineData("123456", " 123456 ", true)] [InlineData("123456", "123457", false)] public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected) { var salt = PasswordResetCodeSecrets.NewSalt(); var hash = PasswordResetCodeSecrets.Hash(ResetKey, salt, issued); PasswordResetCodeSecrets.Matches(ResetKey, salt, candidate, hash).Should().Be(expected); PasswordResetCodeSecrets.Hash(ResetKey, PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash); PasswordResetCodeSecrets.Matches(ResetKey, "", issued, hash).Should().BeFalse(); PasswordResetCodeSecrets.Matches(ResetKey, salt, issued, "").Should().BeFalse(); } }