diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj index 66b0cf6..3ffe10b 100644 --- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj +++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj @@ -1,21 +1,28 @@ - + net7.0 enable + 64377c40-44d6-4989-9662-5d778f8b3b92 - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + - - - - + + + + diff --git a/CleanArchitecture.Api/Controllers/ApiController.cs b/CleanArchitecture.Api/Controllers/ApiController.cs index ddb8cd0..c092d04 100644 --- a/CleanArchitecture.Api/Controllers/ApiController.cs +++ b/CleanArchitecture.Api/Controllers/ApiController.cs @@ -63,6 +63,11 @@ public class ApiController : ControllerBase return HttpStatusCode.NotFound; } + if (_notifications.GetNotifications().Any(n => n.Code == ErrorCodes.InsufficientPermissions)) + { + return HttpStatusCode.Forbidden; + } + return HttpStatusCode.BadRequest; } } \ No newline at end of file diff --git a/CleanArchitecture.Api/Controllers/UserController.cs b/CleanArchitecture.Api/Controllers/UserController.cs index 5d6cfc6..d3986dd 100644 --- a/CleanArchitecture.Api/Controllers/UserController.cs +++ b/CleanArchitecture.Api/Controllers/UserController.cs @@ -1,15 +1,19 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; +using CleanArchitecture.Api.Models; using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Domain.Notifications; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; namespace CleanArchitecture.Api.Controllers; [ApiController] -[Route("[controller]")] +[Route("/api/v1/[controller]")] public class UserController : ApiController { private readonly IUserService _userService; @@ -21,14 +25,20 @@ public class UserController : ApiController _userService = userService; } + [Authorize] [HttpGet] + [SwaggerOperation("Get a list of all users")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage>))] public async Task GetAllUsersAsync() { var users = await _userService.GetAllUsersAsync(); return Response(users); } - - [HttpGet("{id}")] + + [Authorize] + [HttpGet("{id:guid}")] + [SwaggerOperation("Get a user by id")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] public async Task GetUserByIdAsync( [FromRoute] Guid id, [FromQuery] bool isDeleted = false) @@ -36,25 +46,62 @@ public class UserController : ApiController var user = await _userService.GetUserByUserIdAsync(id, isDeleted); return Response(user); } - + + [Authorize] + [HttpGet("me")] + [SwaggerOperation("Get the current active user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] + public async Task GetCurrentUserAsync() + { + var user = await _userService.GetCurrentUserAsync(); + return Response(user); + } + [HttpPost] + [SwaggerOperation("Create a new user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] public async Task CreateUserAsync([FromBody] CreateUserViewModel viewModel) { var userId = await _userService.CreateUserAsync(viewModel); return Response(userId); } - - [HttpDelete("{id}")] + + [Authorize] + [HttpDelete("{id:guid}")] + [SwaggerOperation("Delete a user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] public async Task DeleteUserAsync([FromRoute] Guid id) { await _userService.DeleteUserAsync(id); return Response(id); } - + + [Authorize] [HttpPut] + [SwaggerOperation("Update a user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] public async Task UpdateUserAsync([FromBody] UpdateUserViewModel viewModel) { await _userService.UpdateUserAsync(viewModel); return Response(viewModel); } + + [Authorize] + [HttpPost("changePassword")] + [SwaggerOperation("Change a password for the current active user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] + public async Task ChangePasswordAsync([FromBody] ChangePasswordViewModel viewModel) + { + await _userService.ChangePasswordAsync(viewModel); + return Response(viewModel); + } + + [HttpPost("login")] + [SwaggerOperation("Get a signed token for a user")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] + public async Task LoginUserAsync([FromBody] LoginUserViewModel viewModel) + { + var token = await _userService.LoginUserAsync(viewModel); + return Response(token); + } } \ No newline at end of file diff --git a/CleanArchitecture.Api/Dockerfile b/CleanArchitecture.Api/Dockerfile deleted file mode 100644 index eaa2fcf..0000000 --- a/CleanArchitecture.Api/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /src -COPY ["CleanArchitecture.Api/CleanArchitecture.Api.csproj", "CleanArchitecture.Api/"] -COPY ["CleanArchitecture.Application/CleanArchitecture.Application.csproj", "CleanArchitecture.Application/"] -COPY ["CleanArchitecture.Domain/CleanArchitecture.Domain.csproj", "CleanArchitecture.Domain/"] -COPY ["CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj", "CleanArchitecture.Infrastructure/"] -RUN dotnet restore "CleanArchitecture.Api/CleanArchitecture.Api.csproj" -COPY . . -WORKDIR "/src/CleanArchitecture.Api" -RUN dotnet build "CleanArchitecture.Api.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "CleanArchitecture.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "CleanArchitecture.Api.dll"] \ No newline at end of file diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index 4aee2bc..b828705 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -1,20 +1,66 @@ +using System.Collections.Generic; +using System.Text; using CleanArchitecture.Application.Extensions; using CleanArchitecture.Domain.Extensions; +using CleanArchitecture.Domain.Settings; using CleanArchitecture.gRPC; using CleanArchitecture.Infrastructure.Database; using CleanArchitecture.Infrastructure.Extensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddGrpc(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(c => +{ + c.EnableAnnotations(); + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "CleanArchitecture", + Version = "v1", + Description = "A clean architecture API" + }); + + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. " + + "Use the /api/v1/user/login endpoint to generate a token", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header + }, + new List() + } + }); +}); + +builder.Services.AddHealthChecks(); builder.Services.AddHttpContextAccessor(); builder.Services.AddDbContext(options => @@ -24,38 +70,72 @@ builder.Services.AddDbContext(options => b => b.MigrationsAssembly("CleanArchitecture.Infrastructure")); }); +builder.Services.AddAuthentication( + options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; }) + .AddJwtBearer( + jwtOptions => { jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(); }); + builder.Services.AddInfrastructure(); builder.Services.AddQueryHandlers(); builder.Services.AddServices(); builder.Services.AddCommandHandlers(); builder.Services.AddNotificationHandlers(); +builder.Services.AddApiUser(); -builder.Services.AddMediatR(cfg => -{ - cfg.RegisterServicesFromAssemblies(typeof(Program).Assembly); -}); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Auth")) + .ValidateOnStart(); + +builder.Services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblies(typeof(Program).Assembly); }); var app = builder.Build(); -app.UseSwagger(); -app.UseSwaggerUI(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +app.MapHealthChecks("/health"); app.MapGrpcService(); -using (IServiceScope scope = app.Services.CreateScope()) +using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - ApplicationDbContext appDbContext = services.GetRequiredService(); + var appDbContext = services.GetRequiredService(); appDbContext.EnsureMigrationsApplied(); } app.Run(); -// Needed for integration tests webapplication factory -public partial class Program { } \ No newline at end of file +TokenValidationParameters CreateTokenValidationParameters() +{ + var result = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Auth:Issuer"], + ValidAudience = builder.Configuration["Auth:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes( + builder.Configuration["Auth:Secret"]!)), + RequireSignedTokens = false + }; + + return result; +} + +// Needed for integration tests web application factory +public partial class Program +{ +} \ No newline at end of file diff --git a/CleanArchitecture.Api/appsettings.Development.json b/CleanArchitecture.Api/appsettings.Development.json index 0c208ae..c183fbb 100644 --- a/CleanArchitecture.Api/appsettings.Development.json +++ b/CleanArchitecture.Api/appsettings.Development.json @@ -4,5 +4,13 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Auth": { + "Issuer": "CleanArchitectureServer", + "Audience": "CleanArchitectureClient", + "Secret": "sD3v061gf8BxXgmxcHss" } } diff --git a/CleanArchitecture.Api/appsettings.json b/CleanArchitecture.Api/appsettings.json index 26b0156..c481317 100644 --- a/CleanArchitecture.Api/appsettings.json +++ b/CleanArchitecture.Api/appsettings.json @@ -8,5 +8,10 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Auth": { + "Issuer": "CleanArchitectureServer", + "Audience": "CleanArchitectureClient", + "Secret": "sD3v061gf8BxXgmxcHss" } } diff --git a/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj index 082265f..9af11da 100644 --- a/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj +++ b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,8 +24,8 @@ - - + + diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs index 204e29c..2d995f8 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs @@ -7,7 +7,7 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries; public class QueryHandlerBaseFixture { public Mock Bus { get; } = new(); - + public QueryHandlerBaseFixture VerifyExistingNotification(string key, string errorCode, string message) { Bus.Verify( diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs index 9e22915..9dde37d 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using MockQueryable.Moq; using Moq; @@ -10,28 +11,30 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users; public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture { + public GetAllUsersTestFixture() + { + UserRepository = new Mock(); + + Handler = new GetAllUsersQueryHandler(UserRepository.Object); + } + private Mock UserRepository { get; } public GetAllUsersQueryHandler Handler { get; } public Guid ExistingUserId { get; } = Guid.NewGuid(); - public GetAllUsersTestFixture() - { - UserRepository = new(); - - Handler = new(UserRepository.Object); - } - public void SetupUserAsync() { var user = new Mock(() => - new User( - ExistingUserId, - "max@mustermann.com", - "Max", - "Mustermann")); + new User( + ExistingUserId, + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User)); var query = new[] { user.Object }.AsQueryable().BuildMock(); - + UserRepository .Setup(x => x.GetAllNoTracking()) .Returns(query); @@ -40,11 +43,13 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture public void SetupDeletedUserAsync() { var user = new Mock(() => - new User( - ExistingUserId, - "max@mustermann.com", - "Max", - "Mustermann")); + new User( + ExistingUserId, + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User)); user.Object.Delete(); diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs index 404f699..e175faa 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using MockQueryable.Moq; using Moq; @@ -10,28 +11,30 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users; public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture { + public GetUserByIdTestFixture() + { + UserRepository = new Mock(); + + Handler = new GetUserByIdQueryHandler(UserRepository.Object, Bus.Object); + } + private Mock UserRepository { get; } public GetUserByIdQueryHandler Handler { get; } public Guid ExistingUserId { get; } = Guid.NewGuid(); - public GetUserByIdTestFixture() - { - UserRepository = new(); - - Handler = new(UserRepository.Object, Bus.Object); - } - public void SetupUserAsync() { var user = new Mock(() => new User( - ExistingUserId, - "max@mustermann.com", - "Max", - "Mustermann")); + ExistingUserId, + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User)); var query = new[] { user.Object }.AsQueryable().BuildMock(); - + UserRepository .Setup(x => x.GetAllNoTracking()) .Returns(query); @@ -44,7 +47,9 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture ExistingUserId, "max@mustermann.com", "Max", - "Mustermann")); + "Mustermann", + "Password", + UserRole.User)); user.Object.Delete(); diff --git a/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs index f1ec70e..816c374 100644 --- a/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs +++ b/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Tests.Fixtures.Queries.Users; using FluentAssertions; using Xunit; @@ -16,14 +17,15 @@ public sealed class GetAllUsersQueryHandlerTests _fixture.SetupUserAsync(); var result = await _fixture.Handler.Handle( - new(), + new GetAllUsersQuery(), default); - + _fixture.VerifyNoDomainNotification(); - - result.Should().NotBeNull(); - result.Should().ContainSingle(); - result.FirstOrDefault()!.Id.Should().Be(_fixture.ExistingUserId); + + var userViewModels = result.ToArray(); + userViewModels.Should().NotBeNull(); + userViewModels.Should().ContainSingle(); + userViewModels.FirstOrDefault()!.Id.Should().Be(_fixture.ExistingUserId); } [Fact] @@ -32,7 +34,7 @@ public sealed class GetAllUsersQueryHandlerTests _fixture.SetupDeletedUserAsync(); var result = await _fixture.Handler.Handle( - new(), + new GetAllUsersQuery(), default); _fixture.VerifyNoDomainNotification(); diff --git a/CleanArchitecture.Application.Tests/Queries/Users/GetUserByIdQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Users/GetUserByIdQueryHandlerTests.cs index a65da4c..4b3aec0 100644 --- a/CleanArchitecture.Application.Tests/Queries/Users/GetUserByIdQueryHandlerTests.cs +++ b/CleanArchitecture.Application.Tests/Queries/Users/GetUserByIdQueryHandlerTests.cs @@ -18,15 +18,15 @@ public sealed class GetUserByIdQueryHandlerTests _fixture.SetupUserAsync(); var result = await _fixture.Handler.Handle( - new(_fixture.ExistingUserId, false), + new GetUserByIdQuery(_fixture.ExistingUserId, false), default); - + _fixture.VerifyNoDomainNotification(); result.Should().NotBeNull(); result!.Id.Should().Be(_fixture.ExistingUserId); } - + [Fact] public async Task Should_Raise_Notification_For_No_User() { @@ -36,7 +36,7 @@ public sealed class GetUserByIdQueryHandlerTests var result = await _fixture.Handler.Handle( request, default); - + _fixture.VerifyExistingNotification( nameof(GetUserByIdQuery), ErrorCodes.ObjectNotFound, @@ -51,7 +51,7 @@ public sealed class GetUserByIdQueryHandlerTests _fixture.SetupDeletedUserAsync(); var result = await _fixture.Handler.Handle( - new(_fixture.ExistingUserId, false), + new GetUserByIdQuery(_fixture.ExistingUserId, false), default); _fixture.VerifyExistingNotification( diff --git a/CleanArchitecture.Application/CleanArchitecture.Application.csproj b/CleanArchitecture.Application/CleanArchitecture.Application.csproj index 2f39ab9..2b5f8ef 100644 --- a/CleanArchitecture.Application/CleanArchitecture.Application.csproj +++ b/CleanArchitecture.Application/CleanArchitecture.Application.csproj @@ -6,11 +6,11 @@ - + - + diff --git a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs index beaec9f..784380f 100644 --- a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs @@ -14,15 +14,15 @@ public static class ServiceCollectionExtension public static IServiceCollection AddServices(this IServiceCollection services) { services.AddScoped(); - + return services; } - + public static IServiceCollection AddQueryHandlers(this IServiceCollection services) { services.AddScoped, GetUserByIdQueryHandler>(); services.AddScoped>, GetAllUsersQueryHandler>(); - + return services; } } \ No newline at end of file diff --git a/CleanArchitecture.Application/Interfaces/IUserService.cs b/CleanArchitecture.Application/Interfaces/IUserService.cs index 5f65254..1fbca3e 100644 --- a/CleanArchitecture.Application/Interfaces/IUserService.cs +++ b/CleanArchitecture.Application/Interfaces/IUserService.cs @@ -1,6 +1,6 @@ -using System.Threading.Tasks; using System; using System.Collections.Generic; +using System.Threading.Tasks; using CleanArchitecture.Application.ViewModels.Users; namespace CleanArchitecture.Application.Interfaces; @@ -8,8 +8,11 @@ namespace CleanArchitecture.Application.Interfaces; public interface IUserService { public Task GetUserByUserIdAsync(Guid userId, bool isDeleted); + public Task GetCurrentUserAsync(); public Task> GetAllUsersAsync(); public Task CreateUserAsync(CreateUserViewModel user); public Task UpdateUserAsync(UpdateUserViewModel user); public Task DeleteUserAsync(Guid userId); + public Task ChangePasswordAsync(ChangePasswordViewModel viewModel); + public Task LoginUserAsync(LoginUserViewModel viewModel); } \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs index 40e8571..9e59d7f 100644 --- a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs +++ b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs @@ -4,4 +4,4 @@ using MediatR; namespace CleanArchitecture.Application.Queries.Users.GetAll; -public sealed record GetAllUsersQuery : IRequest>; +public sealed record GetAllUsersQuery : IRequest>; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQuery.cs b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQuery.cs index e394336..b4edf1e 100644 --- a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQuery.cs +++ b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQuery.cs @@ -4,4 +4,4 @@ using MediatR; namespace CleanArchitecture.Application.Queries.Users.GetUserById; -public sealed record GetUserByIdQuery(Guid UserId, bool IsDeleted) : IRequest; +public sealed record GetUserByIdQuery(Guid UserId, bool IsDeleted) : IRequest; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs index 2c166a8..f057731 100644 --- a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs @@ -13,8 +13,8 @@ namespace CleanArchitecture.Application.Queries.Users.GetUserById; public sealed class GetUserByIdQueryHandler : IRequestHandler { - private readonly IUserRepository _userRepository; private readonly IMediatorHandler _bus; + private readonly IUserRepository _userRepository; public GetUserByIdQueryHandler(IUserRepository userRepository, IMediatorHandler bus) { @@ -26,7 +26,7 @@ public sealed class GetUserByIdQueryHandler : { var user = _userRepository .GetAllNoTracking() - .FirstOrDefault(x => + .FirstOrDefault(x => x.Id == request.UserId && x.Deleted == request.IsDeleted); @@ -42,4 +42,4 @@ public sealed class GetUserByIdQueryHandler : return UserViewModel.FromUser(user); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs index dd91d4f..da20411 100644 --- a/CleanArchitecture.Application/Services/UserService.cs +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -5,8 +5,10 @@ using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Commands.Users.ChangePassword; using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser; +using CleanArchitecture.Domain.Commands.Users.LoginUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Interfaces; @@ -15,10 +17,12 @@ namespace CleanArchitecture.Application.Services; public sealed class UserService : IUserService { private readonly IMediatorHandler _bus; + private readonly IUser _user; - public UserService(IMediatorHandler bus) + public UserService(IMediatorHandler bus, IUser user) { _bus = bus; + _user = user; } public async Task GetUserByUserIdAsync(Guid userId, bool isDeleted) @@ -26,11 +30,16 @@ public sealed class UserService : IUserService return await _bus.QueryAsync(new GetUserByIdQuery(userId, isDeleted)); } + public async Task GetCurrentUserAsync() + { + return await _bus.QueryAsync(new GetUserByIdQuery(_user.GetUserId(), false)); + } + public async Task> GetAllUsersAsync() { return await _bus.QueryAsync(new GetAllUsersQuery()); } - + public async Task CreateUserAsync(CreateUserViewModel user) { var userId = Guid.NewGuid(); @@ -39,22 +48,34 @@ public sealed class UserService : IUserService userId, user.Email, user.Surname, - user.GivenName)); + user.GivenName, + user.Password)); return userId; } - + public async Task UpdateUserAsync(UpdateUserViewModel user) { await _bus.SendCommandAsync(new UpdateUserCommand( user.Id, user.Email, user.Surname, - user.GivenName)); + user.GivenName, + user.Role)); } - + public async Task DeleteUserAsync(Guid userId) { await _bus.SendCommandAsync(new DeleteUserCommand(userId)); } + + public async Task ChangePasswordAsync(ChangePasswordViewModel viewModel) + { + await _bus.SendCommandAsync(new ChangePasswordCommand(viewModel.Password, viewModel.NewPassword)); + } + + 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.Application/ViewModels/Users/ChangePasswordViewModel.cs b/CleanArchitecture.Application/ViewModels/Users/ChangePasswordViewModel.cs new file mode 100644 index 0000000..bc35cbb --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Users/ChangePasswordViewModel.cs @@ -0,0 +1,3 @@ +namespace CleanArchitecture.Application.ViewModels.Users; + +public sealed record ChangePasswordViewModel(string Password, string NewPassword); \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Users/LoginUserViewModel.cs b/CleanArchitecture.Application/ViewModels/Users/LoginUserViewModel.cs new file mode 100644 index 0000000..ac5e7de --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Users/LoginUserViewModel.cs @@ -0,0 +1,3 @@ +namespace CleanArchitecture.Application.ViewModels.Users; + +public sealed record LoginUserViewModel(string Email, string Password); \ No newline at end of file diff --git a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs index c1a01d4..3efbb36 100644 --- a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs @@ -3,4 +3,5 @@ namespace CleanArchitecture.Application.ViewModels.Users; public sealed record CreateUserViewModel( string Email, string Surname, - string GivenName); \ No newline at end of file + string GivenName, + string Password); \ No newline at end of file diff --git a/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs index e64fd7d..137d30f 100644 --- a/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs @@ -1,4 +1,5 @@ using System; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Application.ViewModels.Users; @@ -6,4 +7,5 @@ public sealed record UpdateUserViewModel( Guid Id, string Email, string Surname, - string GivenName); \ No newline at end of file + string GivenName, + UserRole Role); \ No newline at end of file diff --git a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs index 9ecc82f..53f2aa9 100644 --- a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Application.ViewModels.Users; @@ -9,6 +10,7 @@ public sealed class UserViewModel public string Email { get; set; } = string.Empty; public string GivenName { get; set; } = string.Empty; public string Surname { get; set; } = string.Empty; + public UserRole Role { get; set; } public static UserViewModel FromUser(User user) { @@ -17,7 +19,8 @@ public sealed class UserViewModel Id = user.Id, Email = user.Email, GivenName = user.GivenName, - Surname = user.Surname + Surname = user.Surname, + Role = user.Role }; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj index 6cc4fcb..dfa6f77 100644 --- a/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj +++ b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj @@ -8,10 +8,11 @@ - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -23,7 +24,7 @@ - + diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs new file mode 100644 index 0000000..08d16ca --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using CleanArchitecture.Domain.Commands.Users.ChangePassword; +using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Events.User; +using Xunit; + +namespace CleanArchitecture.Domain.Tests.CommandHandler.User.ChangePassword; + +public sealed class ChangePasswordCommandHandlerTests +{ + private readonly ChangePasswordCommandTestFixture _fixture = new(); + + [Fact] + public async Task Should_Change_Password() + { + var user = _fixture.SetupUser(); + + var command = new ChangePasswordCommand("z8]tnayvd5FNLU9:]AQm", "z8]tnayvd5FNLU9:]AQw"); + + await _fixture.CommandHandler.Handle(command, default); + + _fixture + .VerifyNoDomainNotification() + .VerifyCommit() + .VerifyRaisedEvent(x => x.UserId == user.Id); + } + + [Fact] + public async Task Should_Not_Change_Password_No_User() + { + var userId = _fixture.SetupMissingUser(); + + var command = new ChangePasswordCommand("z8]tnayvd5FNLU9:]AQm", "z8]tnayvd5FNLU9:]AQw"); + + await _fixture.CommandHandler.Handle(command, default); + + _fixture + .VerifyNoCommit() + .VerifyNoRaisedEvent() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.ObjectNotFound, + $"There is no User with Id {userId}"); + } + + [Fact] + public async Task Should_Not_Change_Password_Incorrect_Password() + { + _fixture.SetupUser(); + + var command = new ChangePasswordCommand("z8]tnayvd5FNLU9:]AQw", "z8]tnayvd5FNLU9:]AQx"); + + await _fixture.CommandHandler.Handle(command, default); + + _fixture + .VerifyNoCommit() + .VerifyNoRaisedEvent() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + DomainErrorCodes.UserPasswordIncorrect, + "The password is incorrect"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs new file mode 100644 index 0000000..74f24c7 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs @@ -0,0 +1,52 @@ +using System; +using CleanArchitecture.Domain.Commands.Users.ChangePassword; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces.Repositories; +using Moq; +using BC = BCrypt.Net.BCrypt; + +namespace CleanArchitecture.Domain.Tests.CommandHandler.User.ChangePassword; + +public sealed class ChangePasswordCommandTestFixture : CommandHandlerFixtureBase +{ + public ChangePasswordCommandTestFixture() + { + UserRepository = new Mock(); + + CommandHandler = new ChangePasswordCommandHandler( + Bus.Object, + UnitOfWork.Object, + NotificationHandler.Object, + UserRepository.Object, + User.Object); + } + + public ChangePasswordCommandHandler CommandHandler { get; } + private Mock UserRepository { get; } + + public Entities.User SetupUser() + { + var user = new Entities.User( + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + BC.HashPassword("z8]tnayvd5FNLU9:]AQm"), + UserRole.User); + + User.Setup(x => x.GetUserId()).Returns(user.Id); + + UserRepository + .Setup(x => x.GetByIdAsync(It.Is(y => y == user.Id))) + .ReturnsAsync(user); + + return user; + } + + public Guid SetupMissingUser() + { + var id = Guid.NewGuid(); + User.Setup(x => x.GetUserId()).Returns(id); + return id; + } +} \ 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 new file mode 100644 index 0000000..39c8186 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs @@ -0,0 +1,97 @@ +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; + +public sealed class ChangePasswordCommandValidationTests : + ValidationTestBase +{ + 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 static ChangePasswordCommand CreateTestCommand( + string? password = null, string? newPassword = null) + { + return new( + password ?? "z8]tnayvd5FNLU9:]AQm", + newPassword ?? "z8]tnayvd5FNLU9:]AQw"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs index 7d5109f..9319274 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs @@ -9,18 +9,19 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.CreateUser; public sealed class CreateUserCommandHandlerTests { private readonly CreateUserCommandTestFixture _fixture = new(); - + [Fact] public void Should_Create_User() { _fixture.SetupUser(); - + var command = new CreateUserCommand( Guid.NewGuid(), "test@email.com", "Test", - "Email"); - + "Email", + "Po=PF]PC6t.?8?ks)A6W"); + _fixture.CommandHandler.Handle(command, default).Wait(); _fixture @@ -28,18 +29,19 @@ public sealed class CreateUserCommandHandlerTests .VerifyCommit() .VerifyRaisedEvent(x => x.UserId == command.UserId); } - + [Fact] public void Should_Not_Create_Already_Existing_User() { var user = _fixture.SetupUser(); - + var command = new CreateUserCommand( user.Id, "test@email.com", "Test", - "Email"); - + "Email", + "Po=PF]PC6t.?8?ks)A6W"); + _fixture.CommandHandler.Handle(command, default).Wait(); _fixture diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs index 2f30515..f6879b3 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Commands.Users.CreateUser; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using Moq; @@ -7,27 +8,29 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.CreateUser; public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase { - public CreateUserCommandHandler CommandHandler { get; } - private Mock UserRepository { get; } - public CreateUserCommandTestFixture() { UserRepository = new Mock(); - - CommandHandler = new( + + CommandHandler = new CreateUserCommandHandler( Bus.Object, UnitOfWork.Object, NotificationHandler.Object, UserRepository.Object); } - + + public CreateUserCommandHandler CommandHandler { get; } + private Mock UserRepository { get; } + public Entities.User SetupUser() { var user = new Entities.User( Guid.NewGuid(), "max@mustermann.com", "Max", - "Mustermann"); + "Mustermann", + "Password", + UserRole.User); UserRepository .Setup(x => x.GetByIdAsync(It.Is(y => y == user.Id))) diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs index a6c54e4..05a8d1b 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; @@ -11,111 +13,181 @@ public sealed class CreateUserCommandValidationTests : public CreateUserCommandValidationTests() : base(new CreateUserCommandValidation()) { } - + [Fact] public void Should_Be_Valid() { var command = CreateTestCommand(); - + ShouldBeValid(command); } - + [Fact] public void Should_Be_Invalid_For_Empty_User_Id() { - var command = CreateTestCommand(userId: Guid.Empty); - + var command = CreateTestCommand(Guid.Empty); + ShouldHaveSingleError( - command, + command, DomainErrorCodes.UserEmptyId, "User id may not be empty"); } - + [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_Surname() { var command = CreateTestCommand(surName: ""); - + ShouldHaveSingleError( command, DomainErrorCodes.UserEmptySurname, "Surname may not be empty"); } - + [Fact] public void Should_Be_Invalid_For_Surname_Exceeds_Max_Length() { var command = CreateTestCommand(surName: new string('a', 101)); - + ShouldHaveSingleError( command, DomainErrorCodes.UserSurnameExceedsMaxLength, "Surname may not be longer than 100 characters"); } - + [Fact] public void Should_Be_Invalid_For_Empty_Given_Name() { var command = CreateTestCommand(givenName: ""); - + ShouldHaveSingleError( command, DomainErrorCodes.UserEmptyGivenName, "Given name may not be empty"); } - + [Fact] public void Should_Be_Invalid_For_Given_Name_Exceeds_Max_Length() { var command = CreateTestCommand(givenName: new string('a', 101)); - + ShouldHaveSingleError( command, DomainErrorCodes.UserGivenNameExceedsMaxLength, "Given name may not be longer than 100 characters"); } - - private CreateUserCommand CreateTestCommand( + + [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 static CreateUserCommand CreateTestCommand( Guid? userId = null, string? email = null, string? surName = null, - string? givenName = null) => - new ( + string? givenName = null, + string? password = null) + { + return new( userId ?? Guid.NewGuid(), email ?? "test@email.com", surName ?? "test", - givenName ?? "email"); + givenName ?? "email", + password ?? "Po=PF]PC6t.?8?ks)A6W"); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs index dc23bd6..fabfd29 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs @@ -9,14 +9,14 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.DeleteUser; public sealed class DeleteUserCommandHandlerTests { private readonly DeleteUserCommandTestFixture _fixture = new(); - + [Fact] public void Should_Delete_User() { var user = _fixture.SetupUser(); - + var command = new DeleteUserCommand(user.Id); - + _fixture.CommandHandler.Handle(command, default).Wait(); _fixture @@ -24,14 +24,14 @@ public sealed class DeleteUserCommandHandlerTests .VerifyCommit() .VerifyRaisedEvent(x => x.UserId == user.Id); } - + [Fact] public void Should_Not_Delete_Non_Existing_User() { _fixture.SetupUser(); - + var command = new DeleteUserCommand(Guid.NewGuid()); - + _fixture.CommandHandler.Handle(command, default).Wait(); _fixture diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs index 19f7761..aaf2e66 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Commands.Users.DeleteUser; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using Moq; @@ -7,27 +8,30 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.DeleteUser; public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase { - public DeleteUserCommandHandler CommandHandler { get; } - private Mock UserRepository { get; } - public DeleteUserCommandTestFixture() { UserRepository = new Mock(); - - CommandHandler = new ( + + CommandHandler = new DeleteUserCommandHandler( Bus.Object, UnitOfWork.Object, NotificationHandler.Object, - UserRepository.Object); + UserRepository.Object, + User.Object); } + public DeleteUserCommandHandler CommandHandler { get; } + private Mock UserRepository { get; } + public Entities.User SetupUser() { var user = new Entities.User( Guid.NewGuid(), "max@mustermann.com", "Max", - "Mustermann"); + "Mustermann", + "Password", + UserRole.User); UserRepository .Setup(x => x.GetByIdAsync(It.Is(y => y == user.Id))) diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs index 50106e5..a2e6e45 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs @@ -16,21 +16,23 @@ public sealed class DeleteUserCommandValidationTests : public void Should_Be_Valid() { var command = CreateTestCommand(); - + ShouldBeValid(command); } - + [Fact] public void Should_Be_Invalid_For_Empty_User_Id() { - var command = CreateTestCommand(userId: Guid.Empty); - + var command = CreateTestCommand(Guid.Empty); + ShouldHaveSingleError( - command, + command, DomainErrorCodes.UserEmptyId, "User id may not be empty"); } - - private DeleteUserCommand CreateTestCommand(Guid? userId = null) => - new (userId ?? Guid.NewGuid()); + + private static DeleteUserCommand CreateTestCommand(Guid? userId = null) + { + return new(userId ?? Guid.NewGuid()); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs new file mode 100644 index 0000000..6137b67 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Commands.Users.LoginUser; +using CleanArchitecture.Domain.Errors; +using FluentAssertions; +using Xunit; + +namespace CleanArchitecture.Domain.Tests.CommandHandler.User.LoginUser; + +public sealed class LoginUserCommandHandlerTests +{ + private readonly LoginUserCommandTestFixture _fixture = new(); + + [Fact] + public async Task Should_Login_User() + { + var user = _fixture.SetupUser(); + + var command = new LoginUserCommand(user.Email, "z8]tnayvd5FNLU9:]AQm"); + + var token = await _fixture.CommandHandler.Handle(command, default); + + _fixture.VerifyNoDomainNotification(); + + token.Should().NotBeNullOrEmpty(); + + var handler = new JwtSecurityTokenHandler(); + var decodedToken = handler.ReadToken(token) as JwtSecurityToken; + + var userIdClaim = decodedToken!.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.NameIdentifier)); + + Guid.Parse(userIdClaim!.Value).Should().Be(user.Id); + + var userEmailClaim = decodedToken!.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Email)); + + userEmailClaim!.Value.Should().Be(user.Email); + + var userRoleClaim = decodedToken!.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Role)); + + userRoleClaim!.Value.Should().Be(user.Role.ToString()); + } + + [Fact] + public async Task Should_Not_Login_User_No_User() + { + var command = new LoginUserCommand("test@email.com", "z8]tnayvd5FNLU9:]AQm"); + + var token = await _fixture.CommandHandler.Handle(command, default); + + _fixture + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.ObjectNotFound, + $"There is no User with Email {command.Email}"); + + token.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Not_Login_User_Incorrect_Password() + { + var user = _fixture.SetupUser(); + + var command = new LoginUserCommand(user.Email, "z8]tnayvd5FNLU9:]AQw"); + + var token = await _fixture.CommandHandler.Handle(command, default); + + _fixture + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + DomainErrorCodes.UserPasswordIncorrect, + "The password is incorrect"); + + token.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs new file mode 100644 index 0000000..71ee339 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs @@ -0,0 +1,55 @@ +using System; +using CleanArchitecture.Domain.Commands.Users.LoginUser; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces.Repositories; +using CleanArchitecture.Domain.Settings; +using Microsoft.Extensions.Options; +using Moq; +using BC = BCrypt.Net.BCrypt; + +namespace CleanArchitecture.Domain.Tests.CommandHandler.User.LoginUser; + +public sealed class LoginUserCommandTestFixture : CommandHandlerFixtureBase +{ + public LoginUserCommandTestFixture() + { + UserRepository = new Mock(); + + TokenSettings = Options.Create(new TokenSettings + { + Issuer = "TestIssuer", + Audience = "TestAudience", + Secret = "asjdlkasjd87439284)@#(*" + }); + + CommandHandler = new LoginUserCommandHandler( + Bus.Object, + UnitOfWork.Object, + NotificationHandler.Object, + UserRepository.Object, + TokenSettings); + } + + public LoginUserCommandHandler CommandHandler { get; set; } + public Mock UserRepository { get; set; } + public IOptions TokenSettings { get; set; } + + public Entities.User SetupUser() + { + var user = new Entities.User( + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + BC.HashPassword("z8]tnayvd5FNLU9:]AQm"), + UserRole.User); + + User.Setup(x => x.GetUserId()).Returns(user.Id); + + UserRepository + .Setup(x => x.GetByEmailAsync(It.Is(y => y == user.Email))) + .ReturnsAsync(user); + + return user; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs new file mode 100644 index 0000000..fd85ce8 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs @@ -0,0 +1,131 @@ +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; + +public sealed class LoginUserCommandValidationTests : + ValidationTestBase +{ + 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(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("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(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 static LoginUserCommand CreateTestCommand( + string? email = null, + string? password = null) + { + return new( + email ?? "test@email.com", + password ?? "Po=PF]PC6t.?8?ks)A6W"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs index bdd53e6..98a13df 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using CleanArchitecture.Domain.Commands.Users.UpdateUser; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using Xunit; @@ -10,18 +11,19 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.UpdateUser; public sealed class UpdateUserCommandHandlerTests { private readonly UpdateUserCommandTestFixture _fixture = new(); - + [Fact] public async Task Should_Update_User() { var user = _fixture.SetupUser(); - + var command = new UpdateUserCommand( user.Id, "test@email.com", "Test", - "Email"); - + "Email", + UserRole.User); + await _fixture.CommandHandler.Handle(command, default); _fixture @@ -29,18 +31,19 @@ public sealed class UpdateUserCommandHandlerTests .VerifyCommit() .VerifyRaisedEvent(x => x.UserId == command.UserId); } - + [Fact] public async Task Should_Not_Update_Non_Existing_User() { _fixture.SetupUser(); - + var command = new UpdateUserCommand( Guid.NewGuid(), "test@email.com", "Test", - "Email"); - + "Email", + UserRole.User); + await _fixture.CommandHandler.Handle(command, default); _fixture diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs index 1b63128..69bb3ca 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Commands.Users.UpdateUser; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using Moq; @@ -7,27 +8,30 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.UpdateUser; public sealed class UpdateUserCommandTestFixture : CommandHandlerFixtureBase { - public UpdateUserCommandHandler CommandHandler { get; } - private Mock UserRepository { get; } - public UpdateUserCommandTestFixture() { UserRepository = new Mock(); - - CommandHandler = new( + + CommandHandler = new UpdateUserCommandHandler( Bus.Object, UnitOfWork.Object, NotificationHandler.Object, - UserRepository.Object); + UserRepository.Object, + User.Object); } - + + public UpdateUserCommandHandler CommandHandler { get; } + private Mock UserRepository { get; } + public Entities.User SetupUser() { var user = new Entities.User( Guid.NewGuid(), "max@mustermann.com", "Max", - "Mustermann"); + "Mustermann", + "Password", + UserRole.User); UserRepository .Setup(x => x.GetByIdAsync(It.Is(y => y == user.Id))) diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs index e593959..2fe4e21 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Commands.Users.UpdateUser; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using Xunit; @@ -11,111 +12,115 @@ public sealed class UpdateUserCommandValidationTests : public UpdateUserCommandValidationTests() : base(new UpdateUserCommandValidation()) { } - + [Fact] public void Should_Be_Valid() { var command = CreateTestCommand(); - + ShouldBeValid(command); } - + [Fact] public void Should_Be_Invalid_For_Empty_User_Id() { - var command = CreateTestCommand(userId: Guid.Empty); - + var command = CreateTestCommand(Guid.Empty); + ShouldHaveSingleError( - command, + command, DomainErrorCodes.UserEmptyId, "User id may not be empty"); } - + [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_Surname() { var command = CreateTestCommand(surName: ""); - + ShouldHaveSingleError( command, DomainErrorCodes.UserEmptySurname, "Surname may not be empty"); } - + [Fact] public void Should_Be_Invalid_For_Surname_Exceeds_Max_Length() { var command = CreateTestCommand(surName: new string('a', 101)); - + ShouldHaveSingleError( command, DomainErrorCodes.UserSurnameExceedsMaxLength, "Surname may not be longer than 100 characters"); } - + [Fact] public void Should_Be_Invalid_For_Empty_Given_Name() { var command = CreateTestCommand(givenName: ""); - + ShouldHaveSingleError( command, DomainErrorCodes.UserEmptyGivenName, "Given name may not be empty"); } - + [Fact] public void Should_Be_Invalid_For_Given_Name_Exceeds_Max_Length() { var command = CreateTestCommand(givenName: new string('a', 101)); - + ShouldHaveSingleError( command, DomainErrorCodes.UserGivenNameExceedsMaxLength, "Given name may not be longer than 100 characters"); } - - private UpdateUserCommand CreateTestCommand( + + private static UpdateUserCommand CreateTestCommand( Guid? userId = null, string? email = null, string? surName = null, - string? givenName = null) => - new ( + string? givenName = null, + UserRole? role = null) + { + return new( userId ?? Guid.NewGuid(), email ?? "test@email.com", surName ?? "test", - givenName ?? "email"); + givenName ?? "email", + role ?? UserRole.User); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs index 01964a0..ce8a493 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Notifications; using Moq; @@ -8,19 +9,24 @@ namespace CleanArchitecture.Domain.Tests; public class CommandHandlerFixtureBase { - protected Mock Bus { get; } - protected Mock UnitOfWork { get; } - protected Mock NotificationHandler { get; } - protected CommandHandlerFixtureBase() { Bus = new Mock(); UnitOfWork = new Mock(); NotificationHandler = new Mock(); + User = new Mock(); + + User.Setup(x => x.GetUserId()).Returns(Guid.NewGuid()); + User.Setup(x => x.GetUserRole()).Returns(UserRole.Admin); UnitOfWork.Setup(unit => unit.CommitAsync()).ReturnsAsync(true); } + protected Mock Bus { get; } + protected Mock UnitOfWork { get; } + protected Mock NotificationHandler { get; } + protected Mock User { get; } + public CommandHandlerFixtureBase VerifyExistingNotification(string errorCode, string message) { Bus.Verify( diff --git a/CleanArchitecture.Domain.Tests/ValidationTestBase.cs b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs index 7f985c8..37391ee 100644 --- a/CleanArchitecture.Domain.Tests/ValidationTestBase.cs +++ b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs @@ -8,7 +8,7 @@ namespace CleanArchitecture.Domain.Tests; public class ValidationTestBase where TCommand : CommandBase - where TValidation: AbstractValidator + where TValidation : AbstractValidator { private readonly TValidation _validation; @@ -54,7 +54,7 @@ public class ValidationTestBase } protected void ShouldHaveExpectedErrors( - TCommand command, + TCommand command, params KeyValuePair[] expectedErrors) { var result = _validation.Validate(command); @@ -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/ApiUser.cs b/CleanArchitecture.Domain/ApiUser.cs new file mode 100644 index 0000000..285bfd6 --- /dev/null +++ b/CleanArchitecture.Domain/ApiUser.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Security.Claims; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace CleanArchitecture.Domain; + +public sealed class ApiUser : IUser +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public ApiUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Guid GetUserId() + { + var claim = _httpContextAccessor.HttpContext?.User.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.NameIdentifier)); + + if (Guid.TryParse(claim?.Value, out var userId)) + { + return userId; + } + + throw new ArgumentException("Could not parse user id to guid"); + } + + public UserRole GetUserRole() + { + var claim = _httpContextAccessor.HttpContext?.User.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Role)); + + if (Enum.TryParse(claim?.Value, out UserRole userRole)) + { + return userRole; + } + + throw new ArgumentException("Could not parse user role"); + } + + public string Name => _httpContextAccessor.HttpContext?.User.Identity?.Name ?? string.Empty; + + public string GetUserEmail() + { + var claim = _httpContextAccessor.HttpContext?.User.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Email)); + + if (!string.IsNullOrWhiteSpace(claim?.Value)) + { + return claim.Value; + } + + throw new ArgumentException("Could not parse user email"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj index 8c0626a..3774cae 100644 --- a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj +++ b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj @@ -6,8 +6,12 @@ - - + + + + + + diff --git a/CleanArchitecture.Domain/Commands/CommandBase.cs b/CleanArchitecture.Domain/Commands/CommandBase.cs index 25f84f6..02d78d1 100644 --- a/CleanArchitecture.Domain/Commands/CommandBase.cs +++ b/CleanArchitecture.Domain/Commands/CommandBase.cs @@ -6,17 +6,17 @@ namespace CleanArchitecture.Domain.Commands; public abstract class CommandBase : IRequest { - public Guid AggregateId { get; } - public string MessageType { get; } - public DateTime Timestamp { get; } - public ValidationResult? ValidationResult { get; protected set; } - protected CommandBase(Guid aggregateId) { MessageType = GetType().Name; Timestamp = DateTime.Now; AggregateId = aggregateId; } - + + public Guid AggregateId { get; } + public string MessageType { get; } + public DateTime Timestamp { get; } + public ValidationResult? ValidationResult { get; protected set; } + public abstract bool IsValid(); } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs b/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs index fe9b36c..54a557b 100644 --- a/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs +++ b/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs @@ -9,21 +9,21 @@ namespace CleanArchitecture.Domain.Commands; public abstract class CommandHandlerBase { - protected readonly IMediatorHandler _bus; - private readonly IUnitOfWork _unitOfWork; + protected readonly IMediatorHandler Bus; private readonly DomainNotificationHandler _notifications; + private readonly IUnitOfWork _unitOfWork; protected CommandHandlerBase( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications) { - _bus = bus; + Bus = bus; _unitOfWork = unitOfWork; _notifications = (DomainNotificationHandler)notifications; } - - public async Task CommitAsync() + + protected async Task CommitAsync() { if (_notifications.HasNotifications()) { @@ -35,7 +35,7 @@ public abstract class CommandHandlerBase return true; } - await _bus.RaiseEventAsync( + await Bus.RaiseEventAsync( new DomainNotification( "Commit", "Problem occured while saving the data. Please try again.", @@ -43,18 +43,18 @@ public abstract class CommandHandlerBase return false; } - + protected async Task NotifyAsync(string key, string message, string code) { - await _bus.RaiseEventAsync( + await Bus.RaiseEventAsync( new DomainNotification(key, message, code)); } protected async Task NotifyAsync(DomainNotification notification) { - await _bus.RaiseEventAsync(notification); + await Bus.RaiseEventAsync(notification); } - + protected async ValueTask TestValidityAsync(CommandBase command) { if (command.IsValid()) @@ -71,8 +71,8 @@ public abstract class CommandHandlerBase { await NotifyAsync( new DomainNotification( - command.MessageType, - error.ErrorMessage, + command.MessageType, + error.ErrorMessage, error.ErrorCode, error.FormattedMessagePlaceholderValues)); } diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..4a27534 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,23 @@ +using System; + +namespace CleanArchitecture.Domain.Commands.Users.ChangePassword; + +public sealed class ChangePasswordCommand : CommandBase +{ + private readonly ChangePasswordCommandValidation _validation = new(); + + public ChangePasswordCommand(string password, string newPassword) : base(Guid.NewGuid()) + { + Password = password; + NewPassword = newPassword; + } + + public string Password { get; } + public string NewPassword { get; } + + public override bool IsValid() + { + ValidationResult = _validation.Validate(this); + return ValidationResult.IsValid; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000..38a85dd --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,71 @@ +using System.Threading; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Events.User; +using CleanArchitecture.Domain.Interfaces; +using CleanArchitecture.Domain.Interfaces.Repositories; +using CleanArchitecture.Domain.Notifications; +using MediatR; +using BC = BCrypt.Net.BCrypt; + +namespace CleanArchitecture.Domain.Commands.Users.ChangePassword; + +public sealed class ChangePasswordCommandHandler : CommandHandlerBase, + IRequestHandler +{ + private readonly IUser _user; + private readonly IUserRepository _userRepository; + + public ChangePasswordCommandHandler( + IMediatorHandler bus, + IUnitOfWork unitOfWork, + INotificationHandler notifications, + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) + { + _userRepository = userRepository; + _user = user; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + if (!await TestValidityAsync(request)) + { + return; + } + + var user = await _userRepository.GetByIdAsync(_user.GetUserId()); + + if (user == null) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + $"There is no User with Id {_user.GetUserId()}", + ErrorCodes.ObjectNotFound)); + + return; + } + + if (!BC.Verify(request.Password, user.Password)) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + "The password is incorrect", + DomainErrorCodes.UserPasswordIncorrect)); + + return; + } + + var passwordHash = BC.HashPassword(request.NewPassword); + user.SetPassword(passwordHash); + + _userRepository.Update(user); + + if (await CommitAsync()) + { + await Bus.RaiseEventAsync(new PasswordChangedEvent(user.Id)); + } + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandValidation.cs new file mode 100644 index 0000000..83ea0d9 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandValidation.cs @@ -0,0 +1,25 @@ +using CleanArchitecture.Domain.Extensions.Validation; +using FluentValidation; + +namespace CleanArchitecture.Domain.Commands.Users.ChangePassword; + +public sealed class ChangePasswordCommandValidation : AbstractValidator +{ + public ChangePasswordCommandValidation() + { + AddRuleForPassword(); + AddRuleForNewPassword(); + } + + private void AddRuleForPassword() + { + RuleFor(cmd => cmd.Password) + .Password(); + } + + private void AddRuleForNewPassword() + { + RuleFor(cmd => cmd.NewPassword) + .Password(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs index 6106289..bafd286 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs @@ -4,25 +4,28 @@ namespace CleanArchitecture.Domain.Commands.Users.CreateUser; public sealed class CreateUserCommand : CommandBase { - private readonly CreateUserCommandValidation _validation = new(); - - public Guid UserId { get; } - public string Email { get; } - public string Surname { get; } - public string GivenName { get; } - + private readonly CreateUserCommandValidation _validation = new(); + public CreateUserCommand( Guid userId, string email, string surname, - string givenName) : base(userId) + string givenName, + string password) : base(userId) { UserId = userId; Email = email; Surname = surname; GivenName = givenName; + Password = password; } + public Guid UserId { get; } + public string Email { get; } + public string Surname { get; } + public string GivenName { get; } + public string Password { get; } + public override bool IsValid() { ValidationResult = _validation.Validate(this); diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs index 0ebbd00..a639591 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs @@ -1,12 +1,14 @@ using System.Threading; using System.Threading.Tasks; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Notifications; using MediatR; +using BC = BCrypt.Net.BCrypt; namespace CleanArchitecture.Domain.Commands.Users.CreateUser; @@ -14,7 +16,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, IRequestHandler { private readonly IUserRepository _userRepository; - + public CreateUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, @@ -35,7 +37,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, if (existingUser != null) { - await _bus.RaiseEventAsync( + await Bus.RaiseEventAsync( new DomainNotification( request.MessageType, $"There is already a User with Id {request.UserId}", @@ -43,17 +45,33 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, return; } + existingUser = await _userRepository.GetByEmailAsync(request.Email); + + if (existingUser != null) + { + await Bus.RaiseEventAsync( + new DomainNotification( + request.MessageType, + $"There is already a User with Email {request.Email}", + DomainErrorCodes.UserAlreadyExists)); + return; + } + + var passwordHash = BC.HashPassword(request.Password); + var user = new User( - request.UserId, + request.UserId, request.Email, request.Surname, - request.GivenName); - + request.GivenName, + passwordHash, + UserRole.User); + _userRepository.Add(user); - + if (await CommitAsync()) { - await _bus.RaiseEventAsync(new UserCreatedEvent(user.Id)); + await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id)); } } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs index 3c14aef..23be1aa 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs @@ -1,4 +1,5 @@ using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Extensions.Validation; using FluentValidation; namespace CleanArchitecture.Domain.Commands.Users.CreateUser; @@ -11,6 +12,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.Email) @@ -31,7 +33,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.Surname) @@ -42,7 +44,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.GivenName) @@ -53,4 +55,10 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.Password) + .Password(); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs index a15d7d6..48c42f5 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs @@ -4,15 +4,15 @@ namespace CleanArchitecture.Domain.Commands.Users.DeleteUser; public sealed class DeleteUserCommand : CommandBase { - private readonly DeleteUserCommandValidation _validation = new(); - - public Guid UserId { get; } - + private readonly DeleteUserCommandValidation _validation = new(); + public DeleteUserCommand(Guid userId) : base(userId) { UserId = userId; } + public Guid UserId { get; } + public override bool IsValid() { ValidationResult = _validation.Validate(this); diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs index edf9b0c..1fc4970 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; @@ -12,15 +13,18 @@ namespace CleanArchitecture.Domain.Commands.Users.DeleteUser; public sealed class DeleteUserCommandHandler : CommandHandlerBase, IRequestHandler { + private readonly IUser _user; private readonly IUserRepository _userRepository; - + public DeleteUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - IUserRepository userRepository) : base(bus, unitOfWork, notifications) + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) { _userRepository = userRepository; + _user = user; } public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken) @@ -31,7 +35,7 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase, } var user = await _userRepository.GetByIdAsync(request.UserId); - + if (user == null) { await NotifyAsync( @@ -43,11 +47,22 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase, return; } + if (_user.GetUserId() != request.UserId && _user.GetUserRole() != UserRole.Admin) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + $"No permission to delete user {request.UserId}", + ErrorCodes.InsufficientPermissions)); + + return; + } + _userRepository.Remove(user); if (await CommitAsync()) { - await _bus.RaiseEventAsync(new UserDeletedEvent(request.UserId)); + await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId)); } } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs new file mode 100644 index 0000000..2fcdc00 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs @@ -0,0 +1,28 @@ +using System; +using MediatR; + +namespace CleanArchitecture.Domain.Commands.Users.LoginUser; + +public sealed class LoginUserCommand : CommandBase, + IRequest +{ + private readonly LoginUserCommandValidation _validation = new(); + + + public LoginUserCommand( + string email, + string password) : base(Guid.NewGuid()) + { + Email = email; + Password = password; + } + + public string Email { get; set; } + public string Password { get; set; } + + public override bool IsValid() + { + ValidationResult = _validation.Validate(this); + return ValidationResult.IsValid; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs new file mode 100644 index 0000000..06cb8d7 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs @@ -0,0 +1,104 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Interfaces; +using CleanArchitecture.Domain.Interfaces.Repositories; +using CleanArchitecture.Domain.Notifications; +using CleanArchitecture.Domain.Settings; +using MediatR; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using BC = BCrypt.Net.BCrypt; + +namespace CleanArchitecture.Domain.Commands.Users.LoginUser; + +public sealed class LoginUserCommandHandler : CommandHandlerBase, + IRequestHandler +{ + private const double ExpiryDurationMinutes = 30; + private readonly TokenSettings _tokenSettings; + + private readonly IUserRepository _userRepository; + + public LoginUserCommandHandler( + IMediatorHandler bus, + IUnitOfWork unitOfWork, + INotificationHandler notifications, + IUserRepository userRepository, + IOptions tokenSettings) : base(bus, unitOfWork, notifications) + { + _userRepository = userRepository; + _tokenSettings = tokenSettings.Value; + } + + public async Task Handle(LoginUserCommand request, CancellationToken cancellationToken) + { + if (!await TestValidityAsync(request)) + { + return ""; + } + + var user = await _userRepository.GetByEmailAsync(request.Email); + + if (user == null) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + $"There is no User with Email {request.Email}", + ErrorCodes.ObjectNotFound)); + + return ""; + } + + var passwordVerified = BC.Verify(request.Password, user.Password); + + if (!passwordVerified) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + "The password is incorrect", + DomainErrorCodes.UserPasswordIncorrect)); + + return ""; + } + + return BuildToken( + user.Email, + user.Role, + user.Id, + _tokenSettings); + } + + private 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()) + }; + + var securityKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(tokenSettings.Secret)); + + var credentials = new SigningCredentials( + securityKey, + SecurityAlgorithms.HmacSha256Signature); + + var tokenDescriptor = new JwtSecurityToken( + tokenSettings.Issuer, + tokenSettings.Audience, + claims, + expires: DateTime.Now.AddMinutes(ExpiryDurationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs new file mode 100644 index 0000000..9f668cd --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs @@ -0,0 +1,31 @@ +using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Extensions.Validation; +using FluentValidation; + +namespace CleanArchitecture.Domain.Commands.Users.LoginUser; + +public sealed class LoginUserCommandValidation : AbstractValidator +{ + public LoginUserCommandValidation() + { + AddRuleForEmail(); + AddRuleForPassword(); + } + + private void AddRuleForEmail() + { + RuleFor(cmd => cmd.Email) + .EmailAddress() + .WithErrorCode(DomainErrorCodes.UserInvalidEmail) + .WithMessage("Email is not a valid email address") + .MaximumLength(320) + .WithErrorCode(DomainErrorCodes.UserEmailExceedsMaxLength) + .WithMessage("Email may not be longer than 320 characters"); + } + + private void AddRuleForPassword() + { + RuleFor(cmd => cmd.Password) + .Password(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs index 44e3617..918bec9 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs @@ -1,28 +1,32 @@ using System; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Domain.Commands.Users.UpdateUser; public sealed class UpdateUserCommand : CommandBase { private readonly UpdateUserCommandValidation _validation = new(); - - public Guid UserId { get; } - public string Email { get; } - public string Surname { get; } - public string GivenName { get; } - + public UpdateUserCommand( Guid userId, string email, string surname, - string givenName) : base(userId) + string givenName, + UserRole role) : base(userId) { UserId = userId; Email = email; Surname = surname; GivenName = givenName; + Role = role; } + public Guid UserId { get; } + public string Email { get; } + public string Surname { get; } + public string GivenName { get; } + public UserRole Role { get; } + public override bool IsValid() { ValidationResult = _validation.Validate(this); diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs index a94c5b5..6f882e3 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; @@ -12,15 +13,18 @@ namespace CleanArchitecture.Domain.Commands.Users.UpdateUser; public sealed class UpdateUserCommandHandler : CommandHandlerBase, IRequestHandler { + private readonly IUser _user; private readonly IUserRepository _userRepository; - + public UpdateUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - IUserRepository userRepository) : base(bus, unitOfWork, notifications) + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) { _userRepository = userRepository; + _user = user; } public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) @@ -34,23 +38,39 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase, if (user == null) { - await _bus.RaiseEventAsync( + await Bus.RaiseEventAsync( new DomainNotification( request.MessageType, $"There is no User with Id {request.UserId}", ErrorCodes.ObjectNotFound)); return; } - + + if (_user.GetUserId() != request.UserId && _user.GetUserRole() != UserRole.Admin) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + $"No permission to update user {request.UserId}", + ErrorCodes.InsufficientPermissions)); + + return; + } + + if (_user.GetUserRole() == UserRole.Admin) + { + user.SetRole(request.Role); + } + user.SetEmail(request.Email); user.SetSurname(request.Surname); user.SetGivenName(request.GivenName); - + _userRepository.Update(user); - + if (await CommitAsync()) { - await _bus.RaiseEventAsync(new UserUpdatedEvent(user.Id)); + await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id)); } } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs index 0b7cc35..f0afc67 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs @@ -11,6 +11,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator cmd.Email) @@ -31,7 +32,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator cmd.Surname) @@ -42,7 +43,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator cmd.GivenName) @@ -53,4 +54,12 @@ public sealed class UpdateUserCommandValidation : AbstractValidator cmd.Role) + .IsInEnum() + .WithErrorCode(DomainErrorCodes.UserInvalidRole) + .WithMessage("Role is not a valid role"); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/Entity.cs b/CleanArchitecture.Domain/Entities/Entity.cs index 395e70e..99a272a 100644 --- a/CleanArchitecture.Domain/Entities/Entity.cs +++ b/CleanArchitecture.Domain/Entities/Entity.cs @@ -31,31 +31,4 @@ public abstract class Entity { Deleted = false; } - - public override bool Equals(object? obj) - { - var compareTo = obj as Entity; - - if (ReferenceEquals(this, compareTo)) - { - return true; - } - - if (compareTo is null) - { - return false; - } - - return Id == compareTo.Id; - } - - public override int GetHashCode() - { - return GetType().GetHashCode() * 907 + Id.GetHashCode(); - } - - public override string ToString() - { - return GetType().Name + " [Id=" + Id + "]"; - } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/User.cs b/CleanArchitecture.Domain/Entities/User.cs index e2c56e9..b92cd3e 100644 --- a/CleanArchitecture.Domain/Entities/User.cs +++ b/CleanArchitecture.Domain/Entities/User.cs @@ -1,27 +1,34 @@ using System; using System.Diagnostics.CodeAnalysis; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Domain.Entities; public class User : Entity { - public string Email { get; private set; } - public string GivenName { get; private set; } - public string Surname { get; private set; } - - public string FullName => $"{Surname}, {GivenName}"; - public User( Guid id, string email, string surname, - string givenName) : base(id) + string givenName, + string password, + UserRole role) : base(id) { Email = email; GivenName = givenName; Surname = surname; + Password = password; + Role = role; } - + + public string Email { get; private set; } + public string GivenName { get; private set; } + public string Surname { get; private set; } + public string Password { get; private set; } + public UserRole Role { get; private set; } + + public string FullName => $"{Surname}, {GivenName}"; + [MemberNotNull(nameof(Email))] public void SetEmail(string email) { @@ -38,7 +45,7 @@ public class User : Entity Email = email; } - + [MemberNotNull(nameof(GivenName))] public void SetGivenName(string givenName) { @@ -55,7 +62,7 @@ public class User : Entity GivenName = givenName; } - + [MemberNotNull(nameof(Surname))] public void SetSurname(string surname) { @@ -72,4 +79,26 @@ public class User : Entity Surname = surname; } + + [MemberNotNull(nameof(Password))] + public void SetPassword(string password) + { + if (password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + if (password.Length > 100) + { + throw new ArgumentException( + "Password may not be longer than 100 characters"); + } + + Password = password; + } + + public void SetRole(UserRole role) + { + Role = role; + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Enums/UserRole.cs b/CleanArchitecture.Domain/Enums/UserRole.cs new file mode 100644 index 0000000..f95d8e1 --- /dev/null +++ b/CleanArchitecture.Domain/Enums/UserRole.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Domain.Enums; + +public enum UserRole +{ + Admin, + User +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs b/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs index 045b455..6c486cf 100644 --- a/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs +++ b/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs @@ -10,7 +10,18 @@ public static class DomainErrorCodes public const string UserSurnameExceedsMaxLength = "USER_SURNAME_EXCEEDS_MAX_LENGTH"; public const string UserGivenNameExceedsMaxLength = "USER_GIVEN_NAME_EXCEEDS_MAX_LENGTH"; public const string UserInvalidEmail = "USER_INVALID_EMAIL"; - + public const string UserInvalidRole = "USER_INVALID_ROLE"; + + // User Password Validation + public const string UserEmptyPassword = "USER_PASSWORD_MAY_NOT_BE_EMPTY"; + public const string UserShortPassword = "USER_PASSWORD_MAY_NOT_BE_SHORTER_THAN_6_CHARACTERS"; + public const string UserLongPassword = "USER_PASSWORD_MAY_NOT_BE_LONGER_THAN_50_CHARACTERS"; + public const string UserUppercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_UPPERCASE_LETTER"; + public const string UserLowercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_LOWERCASE_LETTER"; + public const string UserNumberPassword = "USER_PASSWORD_MUST_CONTAIN_A_NUMBER"; + public const string UserSpecialCharPassword = "USER_PASSWORD_MUST_CONTAIN_A_SPECIAL_CHARACTER"; + // User public const string UserAlreadyExists = "USER_ALREADY_EXISTS"; + public const string UserPasswordIncorrect = "USER_PASSWORD_INCORRECT"; } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Errors/ErrorCodes.cs b/CleanArchitecture.Domain/Errors/ErrorCodes.cs index 056c188..6cc6fd7 100644 --- a/CleanArchitecture.Domain/Errors/ErrorCodes.cs +++ b/CleanArchitecture.Domain/Errors/ErrorCodes.cs @@ -4,4 +4,5 @@ public static class ErrorCodes { public const string CommitFailed = "COMMIT_FAILED"; public const string ObjectNotFound = "OBJECT_NOT_FOUND"; + public const string InsufficientPermissions = "UNAUTHORIZED"; } \ No newline at end of file diff --git a/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs b/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs index 45428b8..3e29e28 100644 --- a/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs +++ b/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs @@ -8,9 +8,10 @@ namespace CleanArchitecture.Domain.EventHandler; public sealed class UserEventHandler : INotificationHandler, INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler { - public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) + public Task Handle(PasswordChangedEvent notification, CancellationToken cancellationToken) { return Task.CompletedTask; } @@ -19,7 +20,12 @@ public sealed class UserEventHandler : { return Task.CompletedTask; } - + + public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + public Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs b/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs new file mode 100644 index 0000000..7ebb021 --- /dev/null +++ b/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs @@ -0,0 +1,13 @@ +using System; + +namespace CleanArchitecture.Domain.Events.User; + +public sealed class PasswordChangedEvent : DomainEvent +{ + public PasswordChangedEvent(Guid userId) : base(userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs index 19f3658..2a67c9b 100644 --- a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs @@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User; public sealed class UserCreatedEvent : DomainEvent { - public Guid UserId { get; } - public UserCreatedEvent(Guid userId) : base(userId) { UserId = userId; } + + public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs index 576bcbf..89fadc1 100644 --- a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs @@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User; public sealed class UserDeletedEvent : DomainEvent { - public Guid UserId { get; } - public UserDeletedEvent(Guid userId) : base(userId) { UserId = userId; } + + public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs index 996c7e6..92a09cf 100644 --- a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs @@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User; public sealed class UserUpdatedEvent : DomainEvent { - public Guid UserId { get; } - public UserUpdatedEvent(Guid userId) : base(userId) { UserId = userId; } + + public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs index 64cd05d..4c3b2b9 100644 --- a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs @@ -1,8 +1,11 @@ +using CleanArchitecture.Domain.Commands.Users.ChangePassword; using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser; +using CleanArchitecture.Domain.Commands.Users.LoginUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.EventHandler; using CleanArchitecture.Domain.Events.User; +using CleanArchitecture.Domain.Interfaces; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -16,17 +19,29 @@ public static class ServiceCollectionExtension services.AddScoped, CreateUserCommandHandler>(); services.AddScoped, UpdateUserCommandHandler>(); services.AddScoped, DeleteUserCommandHandler>(); - + services.AddScoped, ChangePasswordCommandHandler>(); + services.AddScoped, LoginUserCommandHandler>(); + + return services; } - + public static IServiceCollection AddNotificationHandlers(this IServiceCollection services) { // User services.AddScoped, UserEventHandler>(); services.AddScoped, UserEventHandler>(); services.AddScoped, UserEventHandler>(); - + services.AddScoped, UserEventHandler>(); + + return services; + } + + public static IServiceCollection AddApiUser(this IServiceCollection services) + { + // User + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs b/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs new file mode 100644 index 0000000..c94ff0d --- /dev/null +++ b/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; +using CleanArchitecture.Domain.Errors; +using FluentValidation; + +namespace CleanArchitecture.Domain.Extensions.Validation; + +public static class CustomValidator +{ + public static IRuleBuilderOptions StringMustBeBase64(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must(x => IsBase64String(x)); + } + + private static bool IsBase64String(string base64) + { + base64 = base64.Trim(); + return base64.Length % 4 == 0 && Regex.IsMatch(base64, @"^[a-zA-Z0-9\+/]*={0,3}$", RegexOptions.None); + } + + public static IRuleBuilder Password( + this IRuleBuilder ruleBuilder, + int minLength = 8, + int maxLength = 50) + { + var options = ruleBuilder + .NotEmpty().WithErrorCode(DomainErrorCodes.UserEmptyPassword) + .MinimumLength(minLength).WithErrorCode(DomainErrorCodes.UserShortPassword) + .MaximumLength(maxLength).WithErrorCode(DomainErrorCodes.UserLongPassword) + .Matches("[A-Z]").WithErrorCode(DomainErrorCodes.UserUppercaseLetterPassword) + .Matches("[a-z]").WithErrorCode(DomainErrorCodes.UserLowercaseLetterPassword) + .Matches("[0-9]").WithErrorCode(DomainErrorCodes.UserNumberPassword) + .Matches("[^a-zA-Z0-9]").WithErrorCode(DomainErrorCodes.UserSpecialCharPassword); + return options; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/IUser.cs b/CleanArchitecture.Domain/Interfaces/IUser.cs new file mode 100644 index 0000000..26cbe1a --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/IUser.cs @@ -0,0 +1,11 @@ +using System; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Domain.Interfaces; + +public interface IUser +{ + string Name { get; } + Guid GetUserId(); + UserRole GetUserRole(); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Notifications/DomainNotification.cs b/CleanArchitecture.Domain/Notifications/DomainNotification.cs index c9b2719..e57a891 100644 --- a/CleanArchitecture.Domain/Notifications/DomainNotification.cs +++ b/CleanArchitecture.Domain/Notifications/DomainNotification.cs @@ -4,11 +4,6 @@ namespace CleanArchitecture.Domain.Notifications; public sealed class DomainNotification : DomainEvent { - public string Key { get; private set; } - public string Value { get; private set; } - public string Code { get; private set; } - public object? Data { get; set; } - public DomainNotification( string key, string value, @@ -23,4 +18,9 @@ public sealed class DomainNotification : DomainEvent Data = data; } + + public string Key { get; } + public string Value { get; } + public string Code { get; } + public object? Data { get; set; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs b/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs index d1e579d..59f9235 100644 --- a/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs +++ b/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs @@ -15,18 +15,18 @@ public class DomainNotificationHandler : INotificationHandler(); } - public virtual List GetNotifications() - { - return _notifications; - } - public Task Handle(DomainNotification notification, CancellationToken cancellationToken = default) { _notifications.Add(notification); return Task.CompletedTask; } - + + public virtual List GetNotifications() + { + return _notifications; + } + public virtual bool HasNotifications() { return GetNotifications().Any(); diff --git a/CleanArchitecture.Domain/Settings/TokenSettings.cs b/CleanArchitecture.Domain/Settings/TokenSettings.cs new file mode 100644 index 0000000..96cbe01 --- /dev/null +++ b/CleanArchitecture.Domain/Settings/TokenSettings.cs @@ -0,0 +1,8 @@ +namespace CleanArchitecture.Domain.Settings; + +public sealed class TokenSettings +{ + public string Issuer { get; set; } = null!; + public string Audience { get; set; } = null!; + public string Secret { get; set; } = null!; +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj index 574a54c..45957b8 100644 --- a/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj +++ b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj @@ -8,10 +8,10 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -23,7 +23,7 @@ - + diff --git a/CleanArchitecture.Infrastructure.Tests/DomainNotificationHandlerTests.cs b/CleanArchitecture.Infrastructure.Tests/DomainNotificationHandlerTests.cs index 95148af..15a24ed 100644 --- a/CleanArchitecture.Infrastructure.Tests/DomainNotificationHandlerTests.cs +++ b/CleanArchitecture.Infrastructure.Tests/DomainNotificationHandlerTests.cs @@ -16,9 +16,9 @@ public sealed class DomainNotificationHandlerTests [Fact] public void Should_Handle_DomainNotification() { - string key = "Key"; - string value = "Value"; - string code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainNotification = new DomainNotification(key, value, code); var domainNotificationHandler = new DomainNotificationHandler(); @@ -29,9 +29,9 @@ public sealed class DomainNotificationHandlerTests [Fact] public void Should_Handle_DomainNotification_Overload() { - string key = "Key"; - string value = "Value"; - string code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainNotification = new DomainNotification(key, value, code); var domainNotificationHandler = new DomainNotificationHandler(); @@ -42,9 +42,9 @@ public sealed class DomainNotificationHandlerTests [Fact] public void DomainNotification_HasNotifications_After_Handling_One() { - string key = "Key"; - string value = "Value"; - string code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainNotification = new DomainNotification(key, value, code); var domainNotificationHandler = new DomainNotificationHandler(); @@ -60,4 +60,4 @@ public sealed class DomainNotificationHandlerTests domainNotificationHandler.HasNotifications().Should().BeFalse(); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/DomainNotificationTests.cs b/CleanArchitecture.Infrastructure.Tests/DomainNotificationTests.cs index d942a92..14d90b6 100644 --- a/CleanArchitecture.Infrastructure.Tests/DomainNotificationTests.cs +++ b/CleanArchitecture.Infrastructure.Tests/DomainNotificationTests.cs @@ -10,9 +10,9 @@ public sealed class DomainNotificationTests [Fact] public void Should_Create_DomainNotification_Instance() { - string key = "Key"; - string value = "Value"; - string code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainNotification = new DomainNotification( key, value, code); @@ -26,9 +26,9 @@ public sealed class DomainNotificationTests [Fact] public void Should_Create_DomainNotification_Overload_Instance() { - string key = "Key"; - string value = "Value"; - string code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainNotification = new DomainNotification( key, value, code); @@ -38,4 +38,4 @@ public sealed class DomainNotificationTests domainNotification.Code.Should().Be(code); domainNotification.Should().NotBe(default(Guid)); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs b/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs index f3ed4d8..382ccc2 100644 --- a/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs +++ b/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs @@ -19,9 +19,9 @@ public sealed class InMemoryBusTests var inMemoryBus = new InMemoryBus(mediator.Object); - var key = "Key"; - var value = "Value"; - var code = "Code"; + const string key = "Key"; + const string value = "Value"; + const string code = "Code"; var domainEvent = new DomainNotification(key, value, code); diff --git a/CleanArchitecture.Infrastructure.Tests/UnitOfWorkTests.cs b/CleanArchitecture.Infrastructure.Tests/UnitOfWorkTests.cs index 2a0e51f..5700ae1 100644 --- a/CleanArchitecture.Infrastructure.Tests/UnitOfWorkTests.cs +++ b/CleanArchitecture.Infrastructure.Tests/UnitOfWorkTests.cs @@ -19,18 +19,18 @@ public sealed class UnitOfWorkTests var options = new DbContextOptionsBuilder(); var dbContextMock = new Mock(options.Options); var loggerMock = new Mock>>(); - + dbContextMock .Setup(x => x.SaveChangesAsync(CancellationToken.None)) .Returns(Task.FromResult(1)); - + var unitOfWork = UnitOfWorkTestFixture.GetUnitOfWork(dbContextMock.Object, loggerMock.Object); - + var result = await unitOfWork.CommitAsync(); result.Should().BeTrue(); } - + [Fact] public async Task Should_Commit_Async_Returns_False() { @@ -45,7 +45,7 @@ public sealed class UnitOfWorkTests var unitOfWork = UnitOfWorkTestFixture.GetUnitOfWork(dbContextMock.Object, loggerMock.Object); var result = await unitOfWork.CommitAsync(); - + result.Should().BeFalse(); } diff --git a/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj index 6b37ff6..01399f8 100644 --- a/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj +++ b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj @@ -6,18 +6,18 @@ - + - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs index 2723e12..4763c9c 100644 --- a/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs +++ b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs @@ -1,4 +1,6 @@ +using System; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -22,5 +24,19 @@ public sealed class UserConfiguration : IEntityTypeConfiguration .Property(user => user.Surname) .IsRequired() .HasMaxLength(100); + + builder + .Property(user => user.Password) + .IsRequired() + .HasMaxLength(128); + + builder.HasData(new User( + Guid.NewGuid(), + "admin@email.com", + "Admin", + "User", + // !Password123# + "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", + UserRole.Admin)); } } \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs index f4b4323..4280c4d 100644 --- a/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs +++ b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs @@ -6,12 +6,12 @@ namespace CleanArchitecture.Infrastructure.Database; public class ApplicationDbContext : DbContext { - public DbSet Users { get; set; } = null!; - public ApplicationDbContext(DbContextOptions options) : base(options) { } + public DbSet Users { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) { builder.ApplyConfiguration(new UserConfiguration()); diff --git a/CleanArchitecture.Infrastructure/Extensions/DbContextExtension.cs b/CleanArchitecture.Infrastructure/Extensions/DbContextExtension.cs index 9192d48..869c1ea 100644 --- a/CleanArchitecture.Infrastructure/Extensions/DbContextExtension.cs +++ b/CleanArchitecture.Infrastructure/Extensions/DbContextExtension.cs @@ -18,4 +18,4 @@ public static class DbContextExtension context.Database.Migrate(); } } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs index ff72bb8..a7a68a8 100644 --- a/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -22,4 +22,4 @@ public static class ServiceCollectionExtensions return services; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/InMemoryBus.cs b/CleanArchitecture.Infrastructure/InMemoryBus.cs index 5896d83..2df646c 100644 --- a/CleanArchitecture.Infrastructure/InMemoryBus.cs +++ b/CleanArchitecture.Infrastructure/InMemoryBus.cs @@ -14,7 +14,7 @@ public sealed class InMemoryBus : IMediatorHandler { _mediator = mediator; } - + public Task QueryAsync(IRequest query) { return _mediator.Send(query); diff --git a/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.Designer.cs b/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.Designer.cs new file mode 100644 index 0000000..72080c0 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.Designer.cs @@ -0,0 +1,82 @@ +// +using System; +using CleanArchitecture.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanArchitecture.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230320204057_AddUserRoleAndPassword")] + partial class AddUserRoleAndPassword + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.4") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("GivenName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Surname") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"), + Deleted = false, + Email = "admin@email.com", + GivenName = "User", + Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", + Role = 0, + Surname = "Admin" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.cs b/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.cs new file mode 100644 index 0000000..438e65c --- /dev/null +++ b/CleanArchitecture.Infrastructure/Migrations/20230320204057_AddUserRoleAndPassword.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanArchitecture.Infrastructure.Migrations +{ + /// + public partial class AddUserRoleAndPassword : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Password", + table: "Users", + type: "nvarchar(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "Deleted", "Email", "GivenName", "Password", "Role", "Surname" }, + values: new object[] { new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"), false, "admin@email.com", "User", "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", 0, "Admin" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5")); + + migrationBuilder.DropColumn( + name: "Password", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index dcc40d6..249004e 100644 --- a/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace CleanArchitecture.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.3") + .HasAnnotation("ProductVersion", "7.0.4") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true) @@ -44,6 +44,14 @@ namespace CleanArchitecture.Infrastructure.Migrations .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("Password") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + b.Property("Surname") .IsRequired() .HasMaxLength(100) @@ -52,6 +60,18 @@ namespace CleanArchitecture.Infrastructure.Migrations b.HasKey("Id"); b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"), + Deleted = false, + Email = "admin@email.com", + GivenName = "User", + Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", + Role = 0, + Surname = "Admin" + }); }); #pragma warning restore 612, 618 } diff --git a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs index 52b768d..db13d69 100644 --- a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs +++ b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs @@ -50,19 +50,6 @@ public class BaseRepository : IRepository where TEntity : Enti return await DbSet.FindAsync(id); } - public int SaveChanges() - { - return _dbContext.SaveChanges(); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _dbContext.Dispose(); - } - } - public virtual void Update(TEntity entity) { DbSet.Update(entity); @@ -72,7 +59,7 @@ public class BaseRepository : IRepository where TEntity : Enti { return DbSet.AnyAsync(entity => entity.Id == id); } - + public void Remove(TEntity entity, bool hardDelete = false) { if (hardDelete) @@ -86,4 +73,16 @@ public class BaseRepository : IRepository where TEntity : Enti } } + public int SaveChanges() + { + return _dbContext.SaveChanges(); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _dbContext.Dispose(); + } + } } \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/UnitOfWork.cs b/CleanArchitecture.Infrastructure/UnitOfWork.cs index 97d88a9..735590e 100644 --- a/CleanArchitecture.Infrastructure/UnitOfWork.cs +++ b/CleanArchitecture.Infrastructure/UnitOfWork.cs @@ -45,4 +45,4 @@ public sealed class UnitOfWork : IUnitOfWork where TContext : DbContex _context.Dispose(); } } -} +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj index 5f128a0..5768a6d 100644 --- a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj +++ b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj @@ -8,14 +8,14 @@ - - - - - - - - + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -27,8 +27,8 @@ - - + + diff --git a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs index fd8791b..a06ecfa 100644 --- a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs +++ b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.IntegrationTests.Extensions; using CleanArchitecture.IntegrationTests.Fixtures; using FluentAssertions; @@ -23,28 +24,17 @@ public sealed class UserControllerTests : IClassFixture _fixture = fixture; } - [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)] + [Fact] + [Priority(0)] public async Task Should_Create_User() { - var user = new CreateUserViewModel("test@email.com", "Test", "Email"); + var user = new CreateUserViewModel( + _fixture.CreatedUserEmail, + "Test", + "Email", + _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); @@ -55,10 +45,31 @@ public sealed class UserControllerTests : IClassFixture _fixture.CreatedUserId = message!.Data; } - [Fact, Priority(10)] + [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); @@ -74,16 +85,38 @@ public sealed class UserControllerTests : IClassFixture content.GivenName.Should().Be("Email"); } - [Fact, Priority(15)] + [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); + + 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(15)] public async Task Should_Update_User() { var user = new UpdateUserViewModel( _fixture.CreatedUserId, "newtest@email.com", "NewTest", - "NewEmail"); + "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); @@ -96,10 +129,11 @@ public sealed class UserControllerTests : IClassFixture content.Should().BeEquivalentTo(user); } - [Fact, Priority(20)] + [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); @@ -113,12 +147,49 @@ 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_Get_One_User() + [Fact] + [Priority(25)] + public async Task Should_Change_User_Password() { - var response = await _fixture.ServerClient.GetAsync("user"); + 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(30)] + public async Task Should_Get_All_User() + { + var response = await _fixture.ServerClient.GetAsync("/api/v1/user"); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -128,17 +199,28 @@ 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); @@ -149,20 +231,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(); - } -} +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs b/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs index 8cb7fad..d81e2c0 100644 --- a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs +++ b/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs @@ -2,28 +2,29 @@ using System.Collections.Generic; using System.Data.Common; using System.Linq; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; namespace CleanArchitecture.IntegrationTests.Extensions; public static class FunctionalTestsServiceCollectionExtensions { - public static IServiceCollection SetupTestDatabase(this IServiceCollection services, DbConnection connection) where TContext : DbContext + public static IServiceCollection SetupTestDatabase(this IServiceCollection services, + DbConnection connection) where TContext : DbContext { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); if (descriptor != null) services.Remove(descriptor); services.AddScoped(p => - DbContextOptionsFactory( - p, - (_, options) => options - .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)) - .UseLazyLoadingProxies() - .UseSqlite(connection))); + DbContextOptionsFactory( + p, + (_, options) => options + .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)) + .UseLazyLoadingProxies() + .UseSqlite(connection))); return services; } @@ -42,4 +43,4 @@ public static class FunctionalTestsServiceCollectionExtensions return builder.Options; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Extensions/HttpExtensions.cs b/CleanArchitecture.IntegrationTests/Extensions/HttpExtensions.cs index f765a9c..3ac2378 100644 --- a/CleanArchitecture.IntegrationTests/Extensions/HttpExtensions.cs +++ b/CleanArchitecture.IntegrationTests/Extensions/HttpExtensions.cs @@ -10,7 +10,7 @@ public static class HttpExtensions { private static readonly JsonSerializerOptions JsonSerializerOptions = new() { - PropertyNameCaseInsensitive = true, + PropertyNameCaseInsensitive = true }; private static T? Deserialize(string json) @@ -55,4 +55,4 @@ public static class HttpExtensions return httpClient.PutAsync(url, content); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs index 2d4994e..cfe5a52 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs @@ -9,8 +9,6 @@ namespace CleanArchitecture.IntegrationTests.Fixtures; public class TestFixtureBase { - public HttpClient ServerClient { get; } - public TestFixtureBase() { var projectDir = Directory.GetCurrentDirectory(); @@ -25,6 +23,8 @@ public class TestFixtureBase ServerClient.Timeout = TimeSpan.FromMinutes(5); } + public HttpClient ServerClient { get; } + protected virtual void SeedTestData(ApplicationDbContext context) { } @@ -35,4 +35,4 @@ public class TestFixtureBase IServiceProvider scopedServices) { } -} +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs index 5d31bea..5769927 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}"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs index 0e9f243..b35bbdf 100644 --- a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs +++ b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs @@ -18,11 +18,11 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto ServiceProvider serviceProvider, IServiceProvider scopedServices); - private readonly SqliteConnection _connection = new($"DataSource=:memory:"); - private readonly AddCustomSeedDataHandler? _addCustomSeedDataHandler; - private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler; + + private readonly SqliteConnection _connection = new("DataSource=:memory:"); private readonly string? _environment; + private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler; public CleanArchitectureWebApplicationFactory( AddCustomSeedDataHandler? addCustomSeedDataHandler, @@ -51,7 +51,7 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto var sp = services.BuildServiceProvider(); - using IServiceScope scope = sp.CreateScope(); + using var scope = sp.CreateScope(); var scopedServices = scope.ServiceProvider; var applicationDbContext = scopedServices.GetRequiredService(); @@ -62,4 +62,4 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto _registerCustomServicesHandler?.Invoke(services, sp, scopedServices); }); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj b/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj index 1f914a1..de1ac9c 100644 --- a/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj +++ b/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj @@ -6,19 +6,19 @@ - - + + - - + + - - - + + + diff --git a/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj b/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj index 6d11a38..b6443c4 100644 --- a/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj +++ b/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,8 +24,8 @@ - - + + diff --git a/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs b/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs index 63dd2bf..5b04bef 100644 --- a/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs +++ b/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; using MockQueryable.Moq; using Moq; @@ -10,19 +11,31 @@ namespace CleanArchitecture.gRPC.Tests.Fixtures; public sealed class UserTestsFixture { - private Mock UserRepository { get; } = new (); - - public UsersApiImplementation UsersApiImplementation { get; } - - public IEnumerable ExistingUsers { get; } - public UserTestsFixture() { - ExistingUsers = new List() + ExistingUsers = new List { - new (Guid.NewGuid(), "test@test.de", "Test First Name", "Test Last Name"), - new (Guid.NewGuid(), "email@Email.de", "Email First Name", "Email Last Name"), - new (Guid.NewGuid(), "user@user.de", "User First Name", "User Last Name"), + new( + Guid.NewGuid(), + "test@test.de", + "Test First Name", + "Test Last Name", + "Test Password", + UserRole.User), + new( + Guid.NewGuid(), + "email@Email.de", + "Email First Name", + "Email Last Name", + "Email Password", + UserRole.Admin), + new( + Guid.NewGuid(), + "user@user.de", + "User First Name", + "User Last Name", + "User Password", + UserRole.User) }; var queryable = ExistingUsers.AsQueryable().BuildMock(); @@ -33,4 +46,10 @@ public sealed class UserTestsFixture UsersApiImplementation = new UsersApiImplementation(UserRepository.Object); } + + private Mock UserRepository { get; } = new(); + + public UsersApiImplementation UsersApiImplementation { get; } + + public IEnumerable ExistingUsers { get; } } \ No newline at end of file diff --git a/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj b/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj index 82b6f59..1268e7f 100644 --- a/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj +++ b/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj @@ -6,12 +6,12 @@ - - + + - + diff --git a/CleanArchitecture.sln b/CleanArchitecture.sln index 31560fb..1209ff0 100644 --- a/CleanArchitecture.sln +++ b/CleanArchitecture.sln @@ -1,26 +1,31 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Api", "CleanArchitecture.Api\CleanArchitecture.Api.csproj", "{CD720672-0ED9-4FDD-AD69-A416CB394318}" +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33502.453 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Api", "CleanArchitecture.Api\CleanArchitecture.Api.csproj", "{CD720672-0ED9-4FDD-AD69-A416CB394318}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application", "CleanArchitecture.Application\CleanArchitecture.Application.csproj", "{859B50AF-9C8D-4489-B64A-EEBDF756A012}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Application", "CleanArchitecture.Application\CleanArchitecture.Application.csproj", "{859B50AF-9C8D-4489-B64A-EEBDF756A012}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain", "CleanArchitecture.Domain\CleanArchitecture.Domain.csproj", "{12C5BEEF-9BFD-450A-8627-6205702CA32B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Domain", "CleanArchitecture.Domain\CleanArchitecture.Domain.csproj", "{12C5BEEF-9BFD-450A-8627-6205702CA32B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure", "CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj", "{B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Infrastructure", "CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj", "{B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application.Tests", "CleanArchitecture.Application.Tests\CleanArchitecture.Application.Tests.csproj", "{6794B922-2AFD-4187-944D-7984B9973259}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Application.Tests", "CleanArchitecture.Application.Tests\CleanArchitecture.Application.Tests.csproj", "{6794B922-2AFD-4187-944D-7984B9973259}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain.Tests", "CleanArchitecture.Domain.Tests\CleanArchitecture.Domain.Tests.csproj", "{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Domain.Tests", "CleanArchitecture.Domain.Tests\CleanArchitecture.Domain.Tests.csproj", "{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure.Tests", "CleanArchitecture.Infrastructure.Tests\CleanArchitecture.Infrastructure.Tests.csproj", "{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Infrastructure.Tests", "CleanArchitecture.Infrastructure.Tests\CleanArchitecture.Infrastructure.Tests.csproj", "{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.IntegrationTests", "CleanArchitecture.IntegrationTests\CleanArchitecture.IntegrationTests.csproj", "{39732BD4-909F-410C-8737-1F9FE3E269A7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.IntegrationTests", "CleanArchitecture.IntegrationTests\CleanArchitecture.IntegrationTests.csproj", "{39732BD4-909F-410C-8737-1F9FE3E269A7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.gRPC", "CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj", "{7A6353A9-B60C-4B13-A849-D21B315047EE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.gRPC", "CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj", "{7A6353A9-B60C-4B13-A849-D21B315047EE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Proto", "CleanArchitecture.Proto\CleanArchitecture.Proto.csproj", "{5F978903-7A7A-45C2-ABE0-C2906ECD326B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Proto", "CleanArchitecture.Proto\CleanArchitecture.Proto.csproj", "{5F978903-7A7A-45C2-ABE0-C2906ECD326B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.gRPC.Tests", "CleanArchitecture.gRPC.Tests\CleanArchitecture.gRPC.Tests.csproj", "{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.gRPC.Tests", "CleanArchitecture.gRPC.Tests\CleanArchitecture.gRPC.Tests.csproj", "{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -73,4 +78,14 @@ Global {E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.ActiveCfg = Release|Any CPU {E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6794B922-2AFD-4187-944D-7984B9973259} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45} + {E1F25916-EBBE-4CBD-99A2-1EB2F604D55C} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45} + {EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45} + {39732BD4-909F-410C-8737-1F9FE3E269A7} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45} + {E3A836DD-85DB-44FD-BC19-DDFE111D9EB0} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45} + EndGlobalSection EndGlobal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db05150 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /app + +# copy csproj and restore as distinct layers +COPY . . +RUN dotnet restore + +# copy everything else and build app +COPY CleanArchitecture.Api/. ./CleanArchitecture.Api/ +WORKDIR /app/CleanArchitecture.Api +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS runtime +WORKDIR /app +COPY --from=build /app/CleanArchitecture.Api/out ./ +ENTRYPOINT ["dotnet", "CleanArchitecture.Api.dll"] diff --git a/Readme.md b/Readme.md index 2455294..7f36628 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,7 @@ # Clean Architecture Dotnet 7 API Project + +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/alex289/CleanArchitecture/dotnet.yml) + This repository contains a sample API project built using the Clean Architecture principles, Onion Architecture, MediatR, and Entity Framework. The project also includes unit tests for all layers and integration tests using xUnit. ## Project Structure diff --git a/Todo.txt b/Todo.txt deleted file mode 100644 index 3931b92..0000000 --- a/Todo.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Remove warnings and apply suggestions -- Add authentication and authorization