Migrate handlers to UserManager<>

This commit is contained in:
Aaron Po
2026-08-22 01:31:49 -04:00
parent 781d6bb2e6
commit 2e5f02fb38
9 changed files with 205 additions and 299 deletions

View File

@@ -1,27 +1,34 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser; using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
namespace Features.Auth.Tests.Commands; namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests public class ConfirmUserHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
private readonly ConfirmUserHandler _handler; private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public ConfirmUserHandlerTests() public ConfirmUserHandlerTests()
{ {
_authRepositoryMock = new Mock<IAuthRepository>(); _userManagerMock = UserManagerMockFactory.Create();
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object); _handler = new ConfirmUserHandler(
_userManagerMock.Object,
_emailStoreMock.Object,
_tokenServiceMock.Object
);
} }
private static ValidatedToken MakeValidatedToken(Guid userId, string username) private static ValidatedToken MakeValidatedToken(Guid userId, string username)
@@ -44,12 +51,13 @@ public class ConfirmUserHandlerTests
const string confirmationToken = "valid-confirmation-token"; const string confirmationToken = "valid-confirmation-token";
ValidatedToken validatedToken = MakeValidatedToken(userId, username); ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { UserAccountId = userId, Username = username }; ApplicationUser user = new() { Id = userId, UserName = username };
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount); _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
ConfirmationPayload result = await _handler.Handle( ConfirmationPayload result = await _handler.Handle(
new ConfirmUserCommand(confirmationToken), new ConfirmUserCommand(confirmationToken),
@@ -59,6 +67,12 @@ public class ConfirmUserHandlerTests
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId); result.UserAccountId.Should().Be(userId);
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_emailStoreMock.Verify(
x => x.SetEmailConfirmedAsync(user, true, It.IsAny<CancellationToken>()),
Times.Once
);
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
} }
[Fact] [Fact]
@@ -85,9 +99,9 @@ public class ConfirmUserHandlerTests
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(MakeValidatedToken(userId, username)); .ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock _userManagerMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((ApplicationUser?)null);
Func<Task<ConfirmationPayload>> act = async () => Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle( await _handler.Handle(

View File

@@ -1,12 +1,12 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.PasswordHashing;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
using Shared.Application.Emails; using Shared.Application.Emails;
@@ -14,22 +14,19 @@ namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests public class RegisterUserHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler; private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock; private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public RegisterUserHandlerTests() public RegisterUserHandlerTests()
{ {
_authRepoMock = new Mock<IAuthRepository>(); _userManagerMock = UserManagerMockFactory.Create();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_mediatorMock = new Mock<IMediator>(); _mediatorMock = new Mock<IMediator>();
_handler = new RegisterUserHandler( _handler = new RegisterUserHandler(
_authRepoMock.Object, _userManagerMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object, _tokenServiceMock.Object,
_mediatorMock.Object _mediatorMock.Object
); );
@@ -54,42 +51,23 @@ public class RegisterUserHandlerTests
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload() public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{ {
RegisterUserCommand command = ValidCommand(); RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value";
Guid expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.CreateAsync(
It.Is<UserAccount>(ua => It.Is<ApplicationUser>(u =>
ua.FirstName == command.FirstName u.FirstName == command.FirstName
&& ua.LastName == command.LastName && u.LastName == command.LastName
&& ua.Username == command.Username && u.UserName == command.Username
&& ua.Email == command.Email && u.Email == command.Email
&& ua.DateOfBirth == command.DateOfBirth && u.DateOfBirth == command.DateOfBirth
&& ua.UserCredential!.Hash == hashedPassword ),
command.Password
) )
) )
) .Callback<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
.ReturnsAsync( .ReturnsAsync(IdentityResult.Success);
new UserAccount
{
UserAccountId = expectedUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
Email = command.Email,
DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow,
}
);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>())) .Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
@@ -120,18 +98,14 @@ public class RegisterUserHandlerTests
public async Task Handle_WithExistingUsername_ThrowsConflictException() public async Task Handle_WithExistingUsername_ThrowsConflictException()
{ {
RegisterUserCommand command = ValidCommand("existinguser"); RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Username = "existinguser",
};
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(command.Username)) .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync(existingUser); .ReturnsAsync(
_authRepoMock IdentityResult.Failed(
.Setup(x => x.GetUserByEmailAsync(command.Email)) new IdentityError { Code = "DuplicateUserName", Description = "Username taken." }
.ReturnsAsync((UserAccount?)null); )
);
Func<Task<RegistrationPayload>> act = async () => Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
@@ -139,27 +113,20 @@ public class RegisterUserHandlerTests
await act.Should() await act.Should()
.ThrowAsync<ConflictException>() .ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists"); .WithMessage("Username or email already exists");
_authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
Times.Never
);
} }
[Fact] [Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException() public async Task Handle_WithExistingEmail_ThrowsConflictException()
{ {
RegisterUserCommand command = ValidCommand(email: "existing@example.com"); RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Email = "existing@example.com",
};
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(command.Username)) .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync(
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser); IdentityResult.Failed(
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
)
);
Func<Task<RegistrationPayload>> act = async () => Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
@@ -173,23 +140,11 @@ public class RegisterUserHandlerTests
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase() public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{ {
RegisterUserCommand command = ValidCommand(); RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password";
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())) .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync((UserAccount?)null); .Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
_authRepoMock .ReturnsAsync(IdentityResult.Success);
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_tokenServiceMock _tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>())) .Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
@@ -200,18 +155,19 @@ public class RegisterUserHandlerTests
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once); // UserManager.CreateAsync receives the plaintext password and hashes it internally via
_authRepoMock.Verify( // IPasswordHasher<ApplicationUser> -- the handler never sees or stores a hash directly.
_userManagerMock.Verify(
x => x =>
x.RegisterUserAsync( x.CreateAsync(
It.Is<UserAccount>(ua => It.Is<ApplicationUser>(u =>
ua.FirstName == command.FirstName u.FirstName == command.FirstName
&& ua.LastName == command.LastName && u.LastName == command.LastName
&& ua.Username == command.Username && u.UserName == command.Username
&& ua.DateOfBirth == command.DateOfBirth && u.DateOfBirth == command.DateOfBirth
&& ua.Email == command.Email && u.Email == command.Email
&& ua.UserCredential!.Hash == hashedPassword ),
) command.Password
), ),
Times.Once Times.Once
); );
@@ -222,23 +178,10 @@ public class RegisterUserHandlerTests
{ {
RegisterUserCommand command = ValidCommand(); RegisterUserCommand command = ValidCommand();
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())) .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync((UserAccount?)null); .Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
_authRepoMock .ReturnsAsync(IdentityResult.Success);
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>())) .Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))

View File

@@ -1,8 +1,9 @@
using Domain.Entities;
using Features.Auth.Commands.ResendConfirmationEmail; using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
using Shared.Application.Emails; using Shared.Application.Emails;
@@ -10,15 +11,15 @@ namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests public class ResendConfirmationEmailHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly ResendConfirmationEmailHandler _handler; private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new(); private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new(); private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock = UserManagerMockFactory.Create();
public ResendConfirmationEmailHandlerTests() public ResendConfirmationEmailHandlerTests()
{ {
_handler = new ResendConfirmationEmailHandler( _handler = new ResendConfirmationEmailHandler(
_authRepositoryMock.Object, _userManagerMock.Object,
_tokenServiceMock.Object, _tokenServiceMock.Object,
_mediatorMock.Object _mediatorMock.Object
); );
@@ -28,16 +29,18 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{ {
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
UserAccount user = new() ApplicationUser user = new()
{ {
UserAccountId = userId, Id = userId,
FirstName = "Aaron", FirstName = "Aaron",
Email = "aaron@example.com", Email = "aaron@example.com",
}; };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false); _userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false);
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user.UserAccountId, user.Username)).Returns("fresh-token"); _tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName))
.Returns("fresh-token");
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -59,7 +62,9 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_DoesNothing_WhenUserDoesNotExist() public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{ {
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null); _userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -77,10 +82,10 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_DoesNothing_WhenUserAlreadyVerified() public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{ {
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId }; ApplicationUser user = new() { Id = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true); _userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);

View File

@@ -1,32 +1,26 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.Login; using Features.Auth.Commands.Login;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.PasswordHashing; using Microsoft.AspNetCore.Identity;
using Moq; using Moq;
namespace Features.Auth.Tests.Queries; namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests public class LoginHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler; private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public LoginHandlerTests() public LoginHandlerTests()
{ {
_authRepoMock = new Mock<IAuthRepository>(); _userManagerMock = UserManagerMockFactory.Create();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler( _handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object);
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
} }
[Fact] [Fact]
@@ -35,31 +29,20 @@ public class LoginHandlerTests
const string username = "CogitoErgoSum"; const string username = "CogitoErgoSum";
Guid userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() ApplicationUser user = new()
{ {
UserAccountId = userAccountId, Id = userAccountId,
Username = username, UserName = username,
FirstName = "René", FirstName = "René",
LastName = "Descartes", LastName = "Descartes",
Email = "r.descartes@example.com", Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31), DateOfBirth = new DateTime(1596, 03, 31),
}; };
UserCredential userCredential = new() _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
{ _userManagerMock
UserCredentialId = Guid.NewGuid(), .Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
UserAccountId = userAccountId, .ReturnsAsync(true);
Hash = "some-hash",
Expiry = DateTime.MaxValue,
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>())) .Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token"); .Returns("access-token");
@@ -83,16 +66,16 @@ public class LoginHandlerTests
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException() public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{ {
const string username = "de_beauvoir"; const string username = "de_beauvoir";
_authRepoMock _userManagerMock
.Setup(x => x.GetUserByUsernameAsync(username)) .Setup(x => x.FindByNameAsync(username))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((ApplicationUser?)null);
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>(); await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify( _userManagerMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
Times.Never Times.Never
); );
} }
@@ -101,13 +84,10 @@ public class LoginHandlerTests
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException() public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{ {
const string username = "BRussell"; const string username = "BRussell";
Guid userAccountId = Guid.NewGuid(); ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_authRepoMock _userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
@@ -115,32 +95,16 @@ public class LoginHandlerTests
await act.Should() await act.Should()
.ThrowAsync<UnauthorizedException>() .ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password."); .WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
} }
[Fact] [Fact]
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException() public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{ {
const string username = "RCarnap"; const string username = "RCarnap";
Guid userAccountId = Guid.NewGuid(); ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "hashed-password",
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_authRepoMock _userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle( await _handler.Handle(

View File

@@ -0,0 +1,24 @@
using Features.Auth.Identity;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.TestSupport;
public static class UserManagerMockFactory
{
public static Mock<UserManager<ApplicationUser>> Create()
{
Mock<IUserStore<ApplicationUser>> storeMock = new();
return new Mock<UserManager<ApplicationUser>>(
storeMock.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!
);
}
}

View File

@@ -1,9 +1,9 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
@@ -11,8 +11,11 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed. /// corresponding user account as confirmed.
/// </summary> /// </summary>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService) public class ConfirmUserHandler(
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload> UserManager<ApplicationUser> userManager,
IUserEmailStore<ApplicationUser> emailStore,
ITokenService tokenService
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
@@ -26,11 +29,15 @@ public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService to
request.Token request.Token
); );
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId); ApplicationUser? user = await userManager.FindByIdAsync(
validatedToken.UserId.ToString()
);
if (user == null) if (user == null)
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); await emailStore.SetEmailConfirmedAsync(user, true, cancellationToken);
await userManager.UpdateAsync(user);
return new ConfirmationPayload(user.Id, DateTime.UtcNow);
} }
} }

View File

@@ -1,43 +1,35 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.Login; namespace Features.Auth.Commands.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens. /// Handles <see cref="LoginCommand" /> by verifying credentials via
/// <see cref="UserManager{TUser}" /> and issuing access/refresh tokens.
/// </summary> /// </summary>
public class LoginHandler( public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
IAuthRepository authRepo, : IRequestHandler<LoginCommand, LoginPayload>
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : IRequestHandler<LoginCommand, LoginPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential, /// Thrown when the username does not match any account or the supplied password does not match
/// or the supplied password does not match the stored hash. /// the stored hash.
/// </exception> /// </exception>
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
{ {
UserAccount user = ApplicationUser user =
await authRepo.GetUserByUsernameAsync(request.Username) await userManager.FindByNameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password."); ?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords if (!await userManager.CheckPasswordAsync(user, request.Password))
UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password."); throw new UnauthorizedException("Invalid username or password.");
string accessToken = tokenService.GenerateAccessToken(user.UserAccountId, user.Username); string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
string refreshToken = tokenService.GenerateRefreshToken(user.UserAccountId, user.Username); string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken); return new LoginPayload(user.Id, user.UserName, refreshToken, accessToken);
} }
} }

View File

@@ -1,84 +1,62 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails; using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the /// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration /// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
/// confirmation email via Features.Emails. /// attempts to send the registration confirmation email via Features.Emails.
/// </summary> /// </summary>
public class RegisterUserHandler( public class RegisterUserHandler(
IAuthRepository authRepo, UserManager<ApplicationUser> userManager,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService, ITokenService tokenService,
IMediator mediator IMediator mediator
) : IRequestHandler<RegisterUserCommand, RegistrationPayload> ) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{ {
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address. /// Thrown when an existing account already has the same username or email address, or when user
/// creation otherwise fails.
/// </exception> /// </exception>
public async Task<RegistrationPayload> Handle( public async Task<RegistrationPayload> Handle(
RegisterUserCommand request, RegisterUserCommand request,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
await ValidateUserDoesNotExist(request.Username, request.Email); ApplicationUser user = new()
string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync(
new UserAccount
{ {
Username = request.Username, UserName = request.Username,
Email = request.Email,
FirstName = request.FirstName, FirstName = request.FirstName,
LastName = request.LastName, LastName = request.LastName,
DateOfBirth = request.DateOfBirth, DateOfBirth = request.DateOfBirth,
Email = request.Email, };
UserCredential = new UserCredential
{
Hash = hashed,
}
}
);
string accessToken = tokenService.GenerateAccessToken( IdentityResult result = await userManager.CreateAsync(user, request.Password);
createdUser.UserAccountId, if (!result.Succeeded)
createdUser.Username {
); if (result.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail"))
string refreshToken = tokenService.GenerateRefreshToken( throw new ConflictException("Username or email already exists");
createdUser.UserAccountId, throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
createdUser.Username }
);
string confirmationToken = tokenService.GenerateConfirmationToken( string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
createdUser.UserAccountId, string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
createdUser.Username string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return new RegistrationPayload( return new RegistrationPayload(user.Id, user.UserName, string.Empty, string.Empty, false);
createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
bool emailSent = false; bool emailSent = false;
try try
{ {
await mediator.Send( await mediator.Send(
new SendRegistrationEmailCommand( new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken cancellationToken
); );
emailSent = true; emailSent = true;
@@ -90,24 +68,6 @@ public class RegisterUserHandler(
// ignored // ignored
} }
return new RegistrationPayload( return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
}
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
private async Task ValidateUserDoesNotExist(string username, string email)
{
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
throw new ConflictException("Username or email already exists");
} }
} }

View File

@@ -1,7 +1,7 @@
using Domain.Entities; using Features.Auth.Identity;
using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails; using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
@@ -15,7 +15,7 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// or if the user's account is already verified. /// or if the user's account is already verified.
/// </remarks> /// </remarks>
public class ResendConfirmationEmailHandler( public class ResendConfirmationEmailHandler(
IAuthRepository authRepository, UserManager<ApplicationUser> userManager,
ITokenService tokenService, ITokenService tokenService,
IMediator mediator IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand> ) : IRequestHandler<ResendConfirmationEmailCommand>
@@ -25,17 +25,14 @@ public class ResendConfirmationEmailHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId); ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString());
if (user == null) if (user == null)
return; // Silent return to prevent user enumeration return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId)) if (await userManager.IsEmailConfirmedAsync(user))
return; // Already confirmed, no-op return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken( string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
user.UserAccountId,
user.Username
);
await mediator.Send( await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken cancellationToken