From 492ea93b0d4b7dc788f4650e5038a65755b185c3 Mon Sep 17 00:00:00 2001 From: alex289 Date: Wed, 22 Mar 2023 17:30:07 +0100 Subject: [PATCH] Add validation and integration tests --- .../Services/UserService.cs | 1 - .../ChangePasswordCommandValidationTests.cs | 85 +++++++++- .../CreateUserCommandValidationTests.cs | 68 ++++++++ .../LoginUserCommandValidationTests.cs | 120 +++++++++++++- .../ValidationTestBase.cs | 18 +++ .../ChangePasswordCommandHandler.cs | 2 +- .../LoginUser/LoginUserCommandHandler.cs | 8 +- .../Controller/UserControllerTests.cs | 147 ++++++++++++------ .../Fixtures/UserTestFixture.cs | 8 + 9 files changed, 401 insertions(+), 56 deletions(-) diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs index e85af26..ee726a5 100644 --- a/CleanArchitecture.Application/Services/UserService.cs +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -76,7 +76,6 @@ public sealed class UserService : IUserService public async Task LoginUserAsync(LoginUserViewModel viewModel) { - return await _bus.QueryAsync(new LoginUserCommand(viewModel.Email, viewModel.Password)); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs index 52fd44a..d68220c 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs @@ -1,4 +1,8 @@ -using CleanArchitecture.Domain.Commands.Users.ChangePassword; +using System.Collections.Generic; +using System.Linq; +using CleanArchitecture.Domain.Commands.Users.ChangePassword; +using CleanArchitecture.Domain.Errors; +using Xunit; namespace CleanArchitecture.Domain.Tests.CommandHandler.User.ChangePassword; @@ -8,4 +12,83 @@ public sealed class ChangePasswordCommandValidationTests : public ChangePasswordCommandValidationTests() : base(new ChangePasswordCommandValidation()) { } + + [Fact] + public void Should_Be_Valid() + { + var command = CreateTestCommand(); + + ShouldBeValid(command); + } + + [Fact] + public void Should_Be_Invalid_For_Empty_Password() + { + var command = CreateTestCommand(""); + + var errors = new List + { + DomainErrorCodes.UserEmptyPassword, + DomainErrorCodes.UserSpecialCharPassword, + DomainErrorCodes.UserNumberPassword, + DomainErrorCodes.UserLowercaseLetterPassword, + DomainErrorCodes.UserUppercaseLetterPassword, + DomainErrorCodes.UserShortPassword + }; + + ShouldHaveExpectedErrors(command, errors.ToArray()); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Special_Character() + { + var command = CreateTestCommand("z8tnayvd5FNLU9AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Number() + { + var command = CreateTestCommand("z]tnayvdFNLU:]AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Lowercase_Character() + { + var command = CreateTestCommand("Z8]TNAYVDFNLU:]AQM"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Uppercase_Character() + { + var command = CreateTestCommand("z8]tnayvd5fnlu9:]aqm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Short() + { + var command = CreateTestCommand("zA6{"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Long() + { + var command = CreateTestCommand(string.Concat(Enumerable.Repeat("zA6{", 12), 12)); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword); + } + + private ChangePasswordCommand CreateTestCommand( + string? password = null, string? newPassword = null) => new( + password ?? "z8]tnayvd5FNLU9:]AQm", + newPassword ?? "z8]tnayvd5FNLU9:]AQw"); } diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs index e418aa0..61a00ad 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Errors; using Xunit; @@ -108,6 +110,72 @@ public sealed class CreateUserCommandValidationTests : "Given name may not be longer than 100 characters"); } + [Fact] + public void Should_Be_Invalid_For_Empty_Password() + { + var command = CreateTestCommand(password: ""); + + var errors = new List + { + DomainErrorCodes.UserEmptyPassword, + DomainErrorCodes.UserSpecialCharPassword, + DomainErrorCodes.UserNumberPassword, + DomainErrorCodes.UserLowercaseLetterPassword, + DomainErrorCodes.UserUppercaseLetterPassword, + DomainErrorCodes.UserShortPassword + }; + + ShouldHaveExpectedErrors(command, errors.ToArray()); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Special_Character() + { + var command = CreateTestCommand(password: "z8tnayvd5FNLU9AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Number() + { + var command = CreateTestCommand(password: "z]tnayvdFNLU:]AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Lowercase_Character() + { + var command = CreateTestCommand(password: "Z8]TNAYVDFNLU:]AQM"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Uppercase_Character() + { + var command = CreateTestCommand(password: "z8]tnayvd5fnlu9:]aqm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Short() + { + var command = CreateTestCommand(password: "zA6{"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Long() + { + var command = CreateTestCommand(password: string.Concat(Enumerable.Repeat("zA6{", 12), 12)); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword); + } + private CreateUserCommand CreateTestCommand( Guid? userId = null, string? email = null, diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs index 746e492..223f711 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs @@ -1,4 +1,8 @@ -using CleanArchitecture.Domain.Commands.Users.LoginUser; +using System.Collections.Generic; +using System.Linq; +using CleanArchitecture.Domain.Commands.Users.LoginUser; +using CleanArchitecture.Domain.Errors; +using Xunit; namespace CleanArchitecture.Domain.Tests.CommandHandler.User.LoginUser; @@ -8,4 +12,118 @@ public sealed class LoginUserCommandValidationTests : public LoginUserCommandValidationTests() : base(new LoginUserCommandValidation()) { } + + [Fact] + public void Should_Be_Valid() + { + var command = CreateTestCommand(); + + ShouldBeValid(command); + } + + [Fact] + public void Should_Be_Invalid_For_Empty_Email() + { + var command = CreateTestCommand(email: string.Empty); + + ShouldHaveSingleError( + command, + DomainErrorCodes.UserInvalidEmail, + "Email is not a valid email address"); + } + + [Fact] + public void Should_Be_Invalid_For_Invalid_Email() + { + var command = CreateTestCommand(email: "not a email"); + + ShouldHaveSingleError( + command, + DomainErrorCodes.UserInvalidEmail, + "Email is not a valid email address"); + } + + [Fact] + public void Should_Be_Invalid_For_Email_Exceeds_Max_Length() + { + var command = CreateTestCommand(email: new string('a', 320) + "@test.com"); + + ShouldHaveSingleError( + command, + DomainErrorCodes.UserEmailExceedsMaxLength, + "Email may not be longer than 320 characters"); + } + + [Fact] + public void Should_Be_Invalid_For_Empty_Password() + { + var command = CreateTestCommand(password: ""); + + var errors = new List + { + DomainErrorCodes.UserEmptyPassword, + DomainErrorCodes.UserSpecialCharPassword, + DomainErrorCodes.UserNumberPassword, + DomainErrorCodes.UserLowercaseLetterPassword, + DomainErrorCodes.UserUppercaseLetterPassword, + DomainErrorCodes.UserShortPassword + }; + + ShouldHaveExpectedErrors(command, errors.ToArray()); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Special_Character() + { + var command = CreateTestCommand(password: "z8tnayvd5FNLU9AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Number() + { + var command = CreateTestCommand(password: "z]tnayvdFNLU:]AQm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Lowercase_Character() + { + var command = CreateTestCommand(password: "Z8]TNAYVDFNLU:]AQM"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Missing_Uppercase_Character() + { + var command = CreateTestCommand(password: "z8]tnayvd5fnlu9:]aqm"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Short() + { + var command = CreateTestCommand(password: "zA6{"); + + ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword); + } + + [Fact] + public void Should_Be_Invalid_For_Password_Too_Long() + { + var command = CreateTestCommand(password: string.Concat(Enumerable.Repeat("zA6{", 12), 12)); + + ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword); + } + + private LoginUserCommand CreateTestCommand( + string? email = null, + string? password = null) => + new ( + email ?? "test@email.com", + password ?? "Po=PF]PC6t.?8?ks)A6W"); } diff --git a/CleanArchitecture.Domain.Tests/ValidationTestBase.cs b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs index 7f985c8..cbb3692 100644 --- a/CleanArchitecture.Domain.Tests/ValidationTestBase.cs +++ b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs @@ -70,4 +70,22 @@ public class ValidationTestBase .Be(1); } } + + protected void ShouldHaveExpectedErrors( + TCommand command, + params string[] expectedErrors) + { + var result = _validation.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Count.Should().Be(expectedErrors.Length); + + foreach (var error in expectedErrors) + { + result.Errors + .Count(validation => validation.ErrorCode == error) + .Should() + .Be(1); + } + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs index 907088d..4bd41e0 100644 --- a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs @@ -58,7 +58,7 @@ public sealed class ChangePasswordCommandHandler : CommandHandlerBase, return; } - string passwordHash = BC.HashPassword(request.NewPassword); + var passwordHash = BC.HashPassword(request.NewPassword); user.SetPassword(passwordHash); _userRepository.Update(user); diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs index 73fda8a..9998952 100644 --- a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs @@ -20,7 +20,7 @@ namespace CleanArchitecture.Domain.Commands.Users.LoginUser; public sealed class LoginUserCommandHandler : CommandHandlerBase, IRequestHandler { - private const double EXPIRY_DURATION_MINUTES = 30; + private const double ExpiryDurationMinutes = 30; private readonly IUserRepository _userRepository; private readonly TokenSettings _tokenSettings; @@ -76,13 +76,13 @@ public sealed class LoginUserCommandHandler : CommandHandlerBase, _tokenSettings); } - public static string BuildToken(string email, UserRole role, Guid Id, TokenSettings tokenSettings) + public static string BuildToken(string email, UserRole role, Guid id, TokenSettings tokenSettings) { var claims = new[] { new Claim(ClaimTypes.Email, email), new Claim(ClaimTypes.Role, role.ToString()), - new Claim(ClaimTypes.NameIdentifier, Id.ToString()) + new Claim(ClaimTypes.NameIdentifier, id.ToString()) }; var securityKey = new SymmetricSecurityKey( @@ -96,7 +96,7 @@ public sealed class LoginUserCommandHandler : CommandHandlerBase, tokenSettings.Issuer, tokenSettings.Audience, claims, - expires: DateTime.Now.AddMinutes(EXPIRY_DURATION_MINUTES), + expires: DateTime.Now.AddMinutes(ExpiryDurationMinutes), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); diff --git a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs index 20d92be..357dd35 100644 --- a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs +++ b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs @@ -25,31 +25,15 @@ public sealed class UserControllerTests : IClassFixture } [Fact, Priority(0)] - public async Task Should_Get_No_User() - { - var response = await _fixture.ServerClient.GetAsync("user"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var message = await response.Content.ReadAsJsonAsync>(); - - message?.Data.Should().NotBeNull(); - - var content = message!.Data!; - - content.Should().BeNullOrEmpty(); - } - - [Fact, Priority(5)] public async Task Should_Create_User() { var user = new CreateUserViewModel( - "test@email.com", + _fixture.CreatedUserEmail, "Test", "Email", - "Password"); + _fixture.CreatedUserPassword); - var response = await _fixture.ServerClient.PostAsJsonAsync("user", user); + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user", user); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -59,11 +43,49 @@ public sealed class UserControllerTests : IClassFixture _fixture.CreatedUserId = message!.Data; } + + [Fact, Priority(5)] + public async Task Should_Login_User() + { + var user = new LoginUserViewModel( + _fixture.CreatedUserEmail, + _fixture.CreatedUserPassword); + + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/login", user); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + + message?.Data.Should().NotBeEmpty(); + + _fixture.CreatedUserToken = message!.Data!; + _fixture.EnableAuthentication(); + } [Fact, Priority(10)] public async Task Should_Get_Created_Users() { - var response = await _fixture.ServerClient.GetAsync("user/" + _fixture.CreatedUserId); + var response = await _fixture.ServerClient.GetAsync("/api/v1/user/" + _fixture.CreatedUserId); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + + message?.Data.Should().NotBeNull(); + + var content = message!.Data!; + + content.Id.Should().Be(_fixture.CreatedUserId); + content.Email.Should().Be("test@email.com"); + content.Surname.Should().Be("Test"); + content.GivenName.Should().Be("Email"); + } + + [Fact, Priority(10)] + public async Task Should_Get_The_Current_Active_Users() + { + var response = await _fixture.ServerClient.GetAsync("/api/v1/user/me"); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -89,7 +111,7 @@ public sealed class UserControllerTests : IClassFixture "NewEmail", UserRole.User); - var response = await _fixture.ServerClient.PutAsJsonAsync("user", user); + var response = await _fixture.ServerClient.PutAsJsonAsync("/api/v1/user", user); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -105,7 +127,7 @@ public sealed class UserControllerTests : IClassFixture [Fact, Priority(20)] public async Task Should_Get_Updated_Users() { - var response = await _fixture.ServerClient.GetAsync("user/" + _fixture.CreatedUserId); + var response = await _fixture.ServerClient.GetAsync("/api/v1/user/" + _fixture.CreatedUserId); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -119,12 +141,47 @@ public sealed class UserControllerTests : IClassFixture content.Email.Should().Be("newtest@email.com"); content.Surname.Should().Be("NewTest"); content.GivenName.Should().Be("NewEmail"); + + _fixture.CreatedUserEmail = content.Email; + } + + [Fact, Priority(25)] + public async Task Should_Change_User_Password() + { + var user = new ChangePasswordViewModel( + _fixture.CreatedUserPassword, + _fixture.CreatedUserPassword + "1"); + + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/changePassword", user); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + + message?.Data.Should().NotBeNull(); + + var content = message!.Data; + + content.Should().BeEquivalentTo(user); + + // Verify the user can login with the new password + var login = new LoginUserViewModel( + _fixture.CreatedUserEmail, + _fixture.CreatedUserPassword + "1"); + + var loginResponse = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/login", login); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var loginMessage = await loginResponse.Content.ReadAsJsonAsync(); + + loginMessage?.Data.Should().NotBeEmpty(); } - [Fact, Priority(25)] - public async Task Should_Get_One_User() + [Fact, Priority(30)] + public async Task Should_Get_All_User() { - var response = await _fixture.ServerClient.GetAsync("user"); + var response = await _fixture.ServerClient.GetAsync("/api/v1/user"); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -134,17 +191,27 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data!.ToList(); - content.Should().ContainSingle(); - content.First().Id.Should().Be(_fixture.CreatedUserId); - content.First().Email.Should().Be("newtest@email.com"); - content.First().Surname.Should().Be("NewTest"); - content.First().GivenName.Should().Be("NewEmail"); + content.Count.Should().Be(2); + + var currentUser = content.First(x => x.Id == _fixture.CreatedUserId); + + currentUser.Id.Should().Be(_fixture.CreatedUserId); + currentUser.Role.Should().Be(UserRole.User); + currentUser.Email.Should().Be("newtest@email.com"); + currentUser.Surname.Should().Be("NewTest"); + currentUser.GivenName.Should().Be("NewEmail"); + + var adminUser = content.First(x => x.Role == UserRole.Admin); + + adminUser.Email.Should().Be("admin@email.com"); + adminUser.Surname.Should().Be("Admin"); + adminUser.GivenName.Should().Be("User"); } - [Fact, Priority(30)] + [Fact, Priority(35)] public async Task Should_Delete_User() { - var response = await _fixture.ServerClient.DeleteAsync("user/" + _fixture.CreatedUserId); + var response = await _fixture.ServerClient.DeleteAsync("/api/v1/user/" + _fixture.CreatedUserId); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -155,20 +222,4 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data; content.Should().Be(_fixture.CreatedUserId); } - - [Fact, Priority(35)] - public async Task Should_Get_No_User_Again() - { - var response = await _fixture.ServerClient.GetAsync("user"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var message = await response.Content.ReadAsJsonAsync>(); - - message?.Data.Should().NotBeNull(); - - var content = message!.Data!; - - content.Should().BeNullOrEmpty(); - } } diff --git a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs index 5d31bea..3d8fa2e 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs @@ -5,4 +5,12 @@ namespace CleanArchitecture.IntegrationTests.Fixtures; public sealed class UserTestFixture : TestFixtureBase { public Guid CreatedUserId { get; set; } + public string CreatedUserEmail { get; set; } = "test@email.com"; + public string CreatedUserPassword { get; set; } = "z8]tnayvd5FNLU9:]AQm"; + public string CreatedUserToken { get; set; } = string.Empty; + + public void EnableAuthentication() + { + ServerClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {CreatedUserToken}"); + } }