using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; 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 Xunit; namespace Api.SeaHavenIndustries.Tests; /// /// Exercises the password rule through the same Identity registration the API /// host uses, so these assertions describe the rule that runs in production. /// public sealed class PasswordPolicyTests : IAsyncDisposable { private const string CurrentPassword = "Current1!"; private readonly ServiceProvider _provider; private readonly AsyncServiceScope _scope; public PasswordPolicyTests() { var services = new ServiceCollection(); services.AddLogging(); services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); services.AddSeaHavenIdentity(); _provider = services.BuildServiceProvider(); _scope = _provider.CreateAsyncScope(); } private UserManager UserManager => _scope.ServiceProvider.GetRequiredService>(); [Theory] [InlineData("Ab1!x", "PasswordTooShort")] [InlineData("abc12!", "PasswordRequiresUpper")] [InlineData("Abcde!", "PasswordRequiresDigit")] [InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")] public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode) { var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; var errors = await ValidateAsync(user, password); errors.Select(error => error.Code).Should().Equal(expectedCode); } [Theory] [InlineData("Abc1!x")] [InlineData("ABC12!")] public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password) { var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" }; var errors = await ValidateAsync(user, password); errors.Should().BeEmpty(); } [Fact] public void Registration_AppliesSharedPolicyOptions() { var options = _scope.ServiceProvider.GetRequiredService>().Value.Password; options.RequiredLength.Should().Be(6); options.RequireUppercase.Should().BeTrue(); options.RequireDigit.Should().BeTrue(); options.RequireNonAlphanumeric.Should().BeTrue(); options.RequireLowercase.Should().BeFalse(); } [Fact] public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated() { var user = await CreateUserAsync(); var service = NewAuthenticationService(); var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None); result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect); (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); } [Fact] public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy() { var user = await CreateUserAsync(); var service = NewAuthenticationService(); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None); result.Status.Should().Be(ChangePasswordStatus.PasswordRejected); (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); } [Fact] public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword() { var user = await CreateUserAsync(); var service = NewAuthenticationService(); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); result.Status.Should().Be(ChangePasswordStatus.Succeeded); var reloaded = await UserManager.FindByIdAsync(user.Id); (await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue(); } [Theory] [InlineData("ConcurrencyFailure")] [InlineData("PasswordMismatch")] [InlineData("DefaultError")] public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code) { var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" }; var userManager = new Mock>( Mock.Of>(), null!, null!, null!, null!, null!, null!, null!, null!); userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user); userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true); userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x")) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" })); var service = new AuthenticationService( userManager.Object, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), Mock.Of(), Mock.Of(), Mock.Of(), new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, Mock.Of()); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); result.Status.Should().Be(ChangePasswordStatus.Failed); } [Theory] [InlineData("PasswordTooShort", true)] [InlineData("PasswordRequiresUpper", true)] [InlineData("PasswordRequiresDigit", true)] [InlineData("PasswordRequiresNonAlphanumeric", true)] [InlineData("ConcurrencyFailure", false)] [InlineData("PasswordMismatch", false)] public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected) { IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code })) .Should().Be(expected); } [Fact] public async Task ChangePassword_CancelledToken_Throws() { var service = NewAuthenticationService(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token); await act.Should().ThrowAsync(); } [Fact] public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode() { var user = await CreateUserAsync(); var forget = new Mock(); var salt = PasswordResetCodeSecrets.NewSalt(); forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny())) .ReturnsAsync(new ForgetPasswordCode { Id = 7, Email = user.Email!, UserId = user.Id, CodeSalt = salt, CodeHash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('x', 64)), salt, "123456"), ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10) }); forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var service = NewAuthenticationService(forget); var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None); reset.Should().BeFalse(); (await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue(); forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny()), Times.Once); } private async Task> ValidateAsync(ApplicationUser user, string password) { var errors = new List(); foreach (var validator in UserManager.PasswordValidators) { var result = await validator.ValidateAsync(UserManager, user, password); errors.AddRange(result.Errors); } return errors; } private async Task CreateUserAsync() { var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" }; var created = await UserManager.CreateAsync(user, CurrentPassword); created.Succeeded.Should().BeTrue(); return user; } private AuthenticationService NewAuthenticationService(Mock? forget = null) => new( UserManager, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), Mock.Of(), (forget ?? new Mock()).Object, Mock.Of(), new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, Mock.Of()); public async ValueTask DisposeAsync() { await _scope.DisposeAsync(); await _provider.DisposeAsync(); } }