mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Merge Features.Auth and Features.UserManagement into Features.Users
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class ConfirmUserHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
|
||||
private readonly ConfirmUserHandler _handler;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public ConfirmUserHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new ConfirmUserHandler(
|
||||
_userManagerMock.Object,
|
||||
_emailStoreMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||
{
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
|
||||
return new ValidatedToken(userId, username, principal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string confirmationToken = "valid-confirmation-token";
|
||||
|
||||
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
ConfirmationPayload result = await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
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]
|
||||
public async Task Handle_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string invalidToken = "invalid-confirmation-token";
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
||||
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "nonexistent";
|
||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class DeleteAccountHandlerTests
|
||||
{
|
||||
private readonly DeleteAccountHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public DeleteAccountHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new DeleteAccountHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingUser_DeletesAccount()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.DeleteAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
_userManagerMock.Verify(x => x.DeleteAsync(user), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task> act = async () =>
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDependentRecords_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.DeleteAsync(user))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "AccountHasDependentRecords",
|
||||
Description = "still referenced",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task> act = async () =>
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class RefreshTokenHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
||||
{
|
||||
Mock<ITokenService> tokenServiceMock = new();
|
||||
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||
Guid userId = Guid.NewGuid();
|
||||
|
||||
tokenServiceMock
|
||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||
.ReturnsAsync(
|
||||
new RefreshTokenResult(userId, "testuser", "new-refresh-token", "new-access-token")
|
||||
);
|
||||
|
||||
LoginPayload result = await handler.Handle(
|
||||
new RefreshTokenCommand("old-refresh-token"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("testuser");
|
||||
result.RefreshToken.Should().Be("new-refresh-token");
|
||||
result.AccessToken.Should().Be("new-access-token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class RegisterUserHandlerTests
|
||||
{
|
||||
private readonly RegisterUserHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public RegisterUserHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_mediatorMock = new Mock<IMediator>();
|
||||
|
||||
_handler = new RegisterUserHandler(
|
||||
_userManagerMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
private static RegisterUserCommand ValidCommand(
|
||||
string username = "newuser",
|
||||
string email = "john.doe@example.com"
|
||||
)
|
||||
{
|
||||
return new RegisterUserCommand(
|
||||
username,
|
||||
"John",
|
||||
"Doe",
|
||||
email,
|
||||
new DateTime(1990, 1, 1),
|
||||
"SecurePassword123!"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x =>
|
||||
x.CreateAsync(
|
||||
It.Is<ApplicationUser>(u =>
|
||||
u.FirstName == command.FirstName
|
||||
&& u.LastName == command.LastName
|
||||
&& u.UserName == command.Username
|
||||
&& u.Email == command.Email
|
||||
&& u.DateOfBirth == command.DateOfBirth
|
||||
),
|
||||
command.Password
|
||||
)
|
||||
)
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("confirmation-token");
|
||||
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(expectedUserId);
|
||||
result.Username.Should().Be(command.Username);
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
result.RefreshToken.Should().Be("refresh-token");
|
||||
result.ConfirmationEmailSent.Should().BeTrue();
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand("existinguser");
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "DuplicateUserName",
|
||||
Description = "Username taken.",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// UserManager.CreateAsync receives the plaintext password and hashes it internally via
|
||||
// IPasswordHasher<ApplicationUser> -- the handler never sees or stores a hash directly.
|
||||
_userManagerMock.Verify(
|
||||
x =>
|
||||
x.CreateAsync(
|
||||
It.Is<ApplicationUser>(u =>
|
||||
u.FirstName == command.FirstName
|
||||
&& u.LastName == command.LastName
|
||||
&& u.UserName == command.Username
|
||||
&& u.DateOfBirth == command.DateOfBirth
|
||||
&& u.Email == command.Email
|
||||
),
|
||||
command.Password
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
_mediatorMock
|
||||
.Setup(x =>
|
||||
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
|
||||
)
|
||||
.ThrowsAsync(new Exception("smtp down"));
|
||||
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
result.ConfirmationEmailSent.Should().BeFalse();
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class ResendConfirmationEmailHandlerTests
|
||||
{
|
||||
private readonly ResendConfirmationEmailHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock = new();
|
||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock =
|
||||
UserManagerMockFactory.Create();
|
||||
|
||||
public ResendConfirmationEmailHandlerTests()
|
||||
{
|
||||
_handler = new ResendConfirmationEmailHandler(
|
||||
_userManagerMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userId,
|
||||
FirstName = "Aaron",
|
||||
Email = "aaron@example.com",
|
||||
};
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName))
|
||||
.Returns("fresh-token");
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x =>
|
||||
x.Send(
|
||||
It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||
c.FirstName == "Aaron"
|
||||
&& c.Email == "aaron@example.com"
|
||||
&& c.ConfirmationToken == "fresh-token"
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true);
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateEmailHandlerTests
|
||||
{
|
||||
private readonly UpdateEmailHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateEmailHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateEmailHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesEmailAndResetsConfirmation()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userId,
|
||||
Email = "old@example.com",
|
||||
EmailConfirmed = true,
|
||||
};
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetEmailAsync(user, "new@example.com"))
|
||||
.Callback<ApplicationUser, string>(
|
||||
(u, e) =>
|
||||
{
|
||||
u.Email = e;
|
||||
u.EmailConfirmed = false;
|
||||
}
|
||||
)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateEmailPayload result = await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "new@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Email.Should().Be("new@example.com");
|
||||
result.EmailConfirmed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateEmailPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "new@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDuplicateEmail_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, Email = "old@example.com" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetEmailAsync(user, "taken@example.com"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateEmail", Description = "taken" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdateEmailPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "taken@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdatePasswordHandlerTests
|
||||
{
|
||||
private readonly UpdatePasswordHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdatePasswordHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdatePasswordHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_ChangesPassword()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!"))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdatePasswordPayload result = await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdatePasswordPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithWrongCurrentPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.ChangePasswordAsync(user, "WrongPassword1!", "NewPassword1!"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "PasswordMismatch", Description = "mismatch" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdatePasswordPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "WrongPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateProfileHandlerTests
|
||||
{
|
||||
private readonly UpdateProfileHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateProfileHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateProfileHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesProfileFields()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userId,
|
||||
FirstName = "Old",
|
||||
LastName = "Name",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
DateTime newDob = new(1991, 2, 3);
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateProfilePayload result = await _handler.Handle(
|
||||
new UpdateProfileCommand(userId, "New", "Person", newDob),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.FirstName.Should().Be("New");
|
||||
result.LastName.Should().Be("Person");
|
||||
result.DateOfBirth.Should().Be(newDob);
|
||||
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateProfilePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateProfileCommand(userId, "New", "Person", DateTime.Today),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateUsernameHandlerTests
|
||||
{
|
||||
private readonly UpdateUsernameHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateUsernameHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateUsernameHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesUsername()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetUserNameAsync(user, "new-name"))
|
||||
.Callback<ApplicationUser, string>((u, n) => u.UserName = n)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateUsernamePayload result = await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "new-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("new-name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateUsernamePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "new-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDuplicateUsername_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetUserNameAsync(user, "taken-name"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateUserName", Description = "taken" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdateUsernamePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "taken-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Features.Auth.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Users\Features.Users.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Queries.GetAllUsers;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class GetAllUsersHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<UserAccount> result = await handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Queries.GetUserById;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class GetUserByIdHandlerTests
|
||||
{
|
||||
private readonly GetUserByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
|
||||
public GetUserByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetUserByIdHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsUser_WhenFound()
|
||||
{
|
||||
UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
UserAccount result = await _handler.Handle(
|
||||
new GetUserByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().Be(user);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Throws_WhenNotFound()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<UserAccount>> act = async () =>
|
||||
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class LoginHandlerTests
|
||||
{
|
||||
private readonly LoginHandler _handler;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public LoginHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||
{
|
||||
const string username = "CogitoErgoSum";
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userAccountId,
|
||||
UserName = username,
|
||||
FirstName = "René",
|
||||
LastName = "Descartes",
|
||||
Email = "r.descartes@example.com",
|
||||
DateOfBirth = new DateTime(1596, 03, 31),
|
||||
};
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
LoginPayload result = await _handler.Handle(
|
||||
new LoginCommand(username, "any-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userAccountId);
|
||||
result.Username.Should().Be(username);
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
result.RefreshToken.Should().Be("refresh-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "de_beauvoir";
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByNameAsync(username))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
_userManagerMock.Verify(
|
||||
x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "BRussell";
|
||||
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "RCarnap";
|
||||
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new LoginCommand(username, "wrong-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Services;
|
||||
|
||||
public class TokenServiceRefreshTests
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly TokenService _tokenService;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public TokenServiceRefreshTests()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[ConfigurationKeys.AccessTokenSecret] =
|
||||
"test-access-secret-that-is-very-long-1234567890",
|
||||
[ConfigurationKeys.RefreshTokenSecret] =
|
||||
"test-refresh-secret-that-is-very-long-1234567890",
|
||||
[ConfigurationKeys.ConfirmationTokenSecret] =
|
||||
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_userManagerMock.Object,
|
||||
configuration
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-token";
|
||||
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.Returns(
|
||||
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
|
||||
);
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
|
||||
// Act
|
||||
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_userManagerMock.Verify(x => x.FindByIdAsync(userId.ToString()), Times.Once);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_tokenInfraMock.Verify(
|
||||
x =>
|
||||
x.GenerateJwt(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Exactly(2)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-token";
|
||||
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Configuration;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Services;
|
||||
|
||||
public class TokenServiceValidationTests
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceValidationTests()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[ConfigurationKeys.AccessTokenSecret] =
|
||||
"test-access-secret-that-is-very-long-1234567890",
|
||||
[ConfigurationKeys.RefreshTokenSecret] =
|
||||
"test-refresh-secret-that-is-very-long-1234567890",
|
||||
[ConfigurationKeys.ConfirmationTokenSecret] =
|
||||
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
UserManagerMockFactory.Create().Object,
|
||||
configuration
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-refresh-token";
|
||||
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-confirmation-token";
|
||||
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "expired-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "testuser";
|
||||
const string token = "token-without-user-id";
|
||||
|
||||
// Claims without Sub (user ID)
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string token = "token-without-username";
|
||||
|
||||
// Claims without UniqueName (username)
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "testuser";
|
||||
const string token = "token-with-malformed-user-id";
|
||||
|
||||
// Claims with invalid GUID format
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*malformed user ID*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-confirmation-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user