shoc-backend/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Alexandre Brandizzi 66a49ab957 feat(auth): enforce one password policy on every password-setting path (SH-386)
Both hosts now apply the same Identity password rule: at least 6 characters
with one uppercase letter, one number and one special character. Change
password requires an authenticated caller, verifies the current password
before evaluating the new one, and reports a policy rejection separately
from a wrong current password.
2026-09-25 11:06:42 -03:00

180 lines
6.9 KiB
C#

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.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
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<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[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<IOptions<IdentityOptions>>().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();
}
[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<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
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<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> 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<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}