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.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; var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions); var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions); return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, sessionStamps); } 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(); } [Fact] public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail() { 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(); var service = NewService(userData, forget, email, out _, out _); var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); found.Should().BeTrue(); forget.Verify( f => f.ReplaceCodeAsync( user.Email!, user.Id, It.Is(code => code.Length == 6 && code.All(char.IsDigit)), It.IsAny()), Times.Once); email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); } [Fact] public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() { var userData = new Mock(); userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); var forget = new Mock(); var email = new Mock(); var service = NewService(userData, forget, email, out _, out _); var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); found.Should().BeFalse(); forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData(true)] [InlineData(false)] public async Task VerifyCode_ForwardsDataServiceResult(bool exists) { var forget = new Mock(); forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); var service = NewService(new Mock(), forget, new Mock(), out _, out _); var result = await service.VerifyCodeAsync("abc", CancellationToken.None); result.Should().Be(exists); } [Fact] public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset() { var forget = new Mock(); forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), 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", "999", "new", CancellationToken.None); result.Should().BeFalse(); store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); } }