mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
# Conflicts: # Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs # Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs # SeaHaven.Services/Implementation/AuthenticationService.cs
235 lines
9.5 KiB
C#
235 lines
9.5 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.Helpers;
|
|
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();
|
|
}
|
|
|
|
[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<UserManager<ApplicationUser>>(
|
|
Mock.Of<IUserStore<ApplicationUser>>(), 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<IUserDataService>(),
|
|
Mock.Of<IForgetPasswordDataService>(),
|
|
Mock.Of<IPasswordResetEmailQueue>(),
|
|
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
|
TimeProvider.System,
|
|
Mock.Of<ISessionStampService>());
|
|
|
|
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<OperationCanceledException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
|
|
{
|
|
var user = await CreateUserAsync();
|
|
var forget = new Mock<IForgetPasswordDataService>();
|
|
var salt = PasswordResetCodeSecrets.NewSalt();
|
|
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
|
|
.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<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
|
.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<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
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<IPasswordResetEmailQueue>(),
|
|
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
|
TimeProvider.System,
|
|
Mock.Of<ISessionStampService>());
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _scope.DisposeAsync();
|
|
await _provider.DisposeAsync();
|
|
}
|
|
}
|