0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-07-06 05:53:55 +00:00

Merge pull request #1 from alex289/feature/authentication

Add user authentication
This commit is contained in:
Alex 2023-03-22 19:47:45 +01:00 committed by GitHub
commit 8cd79c37fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 2145 additions and 544 deletions

View File

@ -1,14 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UserSecretsId>64377c40-44d6-4989-9662-5d778f8b3b92</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.4"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.4"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.4"/> <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.4"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -63,6 +63,11 @@ public class ApiController : ControllerBase
return HttpStatusCode.NotFound; return HttpStatusCode.NotFound;
} }
if (_notifications.GetNotifications().Any(n => n.Code == ErrorCodes.InsufficientPermissions))
{
return HttpStatusCode.Forbidden;
}
return HttpStatusCode.BadRequest; return HttpStatusCode.BadRequest;
} }
} }

View File

@ -1,15 +1,19 @@
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Api.Models;
using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Application.ViewModels.Users;
using CleanArchitecture.Domain.Notifications; using CleanArchitecture.Domain.Notifications;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace CleanArchitecture.Api.Controllers; namespace CleanArchitecture.Api.Controllers;
[ApiController] [ApiController]
[Route("[controller]")] [Route("/api/v1/[controller]")]
public class UserController : ApiController public class UserController : ApiController
{ {
private readonly IUserService _userService; private readonly IUserService _userService;
@ -21,14 +25,20 @@ public class UserController : ApiController
_userService = userService; _userService = userService;
} }
[Authorize]
[HttpGet] [HttpGet]
[SwaggerOperation("Get a list of all users")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<IEnumerable<UserViewModel>>))]
public async Task<IActionResult> GetAllUsersAsync() public async Task<IActionResult> GetAllUsersAsync()
{ {
var users = await _userService.GetAllUsersAsync(); var users = await _userService.GetAllUsersAsync();
return Response(users); return Response(users);
} }
[HttpGet("{id}")] [Authorize]
[HttpGet("{id:guid}")]
[SwaggerOperation("Get a user by id")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UserViewModel>))]
public async Task<IActionResult> GetUserByIdAsync( public async Task<IActionResult> GetUserByIdAsync(
[FromRoute] Guid id, [FromRoute] Guid id,
[FromQuery] bool isDeleted = false) [FromQuery] bool isDeleted = false)
@ -37,24 +47,61 @@ public class UserController : ApiController
return Response(user); return Response(user);
} }
[Authorize]
[HttpGet("me")]
[SwaggerOperation("Get the current active user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UserViewModel>))]
public async Task<IActionResult> GetCurrentUserAsync()
{
var user = await _userService.GetCurrentUserAsync();
return Response(user);
}
[HttpPost] [HttpPost]
[SwaggerOperation("Create a new user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<Guid>))]
public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserViewModel viewModel) public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserViewModel viewModel)
{ {
var userId = await _userService.CreateUserAsync(viewModel); var userId = await _userService.CreateUserAsync(viewModel);
return Response(userId); return Response(userId);
} }
[HttpDelete("{id}")] [Authorize]
[HttpDelete("{id:guid}")]
[SwaggerOperation("Delete a user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<Guid>))]
public async Task<IActionResult> DeleteUserAsync([FromRoute] Guid id) public async Task<IActionResult> DeleteUserAsync([FromRoute] Guid id)
{ {
await _userService.DeleteUserAsync(id); await _userService.DeleteUserAsync(id);
return Response(id); return Response(id);
} }
[Authorize]
[HttpPut] [HttpPut]
[SwaggerOperation("Update a user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UpdateUserViewModel>))]
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserViewModel viewModel) public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserViewModel viewModel)
{ {
await _userService.UpdateUserAsync(viewModel); await _userService.UpdateUserAsync(viewModel);
return Response(viewModel); return Response(viewModel);
} }
[Authorize]
[HttpPost("changePassword")]
[SwaggerOperation("Change a password for the current active user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<ChangePasswordViewModel>))]
public async Task<IActionResult> 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<string>))]
public async Task<IActionResult> LoginUserAsync([FromBody] LoginUserViewModel viewModel)
{
var token = await _userService.LoginUserAsync(viewModel);
return Response(token);
}
} }

View File

@ -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"]

View File

@ -1,20 +1,66 @@
using System.Collections.Generic;
using System.Text;
using CleanArchitecture.Application.Extensions; using CleanArchitecture.Application.Extensions;
using CleanArchitecture.Domain.Extensions; using CleanArchitecture.Domain.Extensions;
using CleanArchitecture.Domain.Settings;
using CleanArchitecture.gRPC; using CleanArchitecture.gRPC;
using CleanArchitecture.Infrastructure.Database; using CleanArchitecture.Infrastructure.Database;
using CleanArchitecture.Infrastructure.Extensions; using CleanArchitecture.Infrastructure.Extensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddGrpc(); builder.Services.AddGrpc();
builder.Services.AddEndpointsApiExplorer(); 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<string>()
}
});
});
builder.Services.AddHealthChecks();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddDbContext<ApplicationDbContext>(options => builder.Services.AddDbContext<ApplicationDbContext>(options =>
@ -24,38 +70,72 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
b => b.MigrationsAssembly("CleanArchitecture.Infrastructure")); b => b.MigrationsAssembly("CleanArchitecture.Infrastructure"));
}); });
builder.Services.AddAuthentication(
options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; })
.AddJwtBearer(
jwtOptions => { jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(); });
builder.Services.AddInfrastructure(); builder.Services.AddInfrastructure();
builder.Services.AddQueryHandlers(); builder.Services.AddQueryHandlers();
builder.Services.AddServices(); builder.Services.AddServices();
builder.Services.AddCommandHandlers(); builder.Services.AddCommandHandlers();
builder.Services.AddNotificationHandlers(); builder.Services.AddNotificationHandlers();
builder.Services.AddApiUser();
builder.Services.AddMediatR(cfg => builder.Services
{ .AddOptions<TokenSettings>()
cfg.RegisterServicesFromAssemblies(typeof(Program).Assembly); .Bind(builder.Configuration.GetSection("Auth"))
}); .ValidateOnStart();
builder.Services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblies(typeof(Program).Assembly); });
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
}
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/health");
app.MapGrpcService<UsersApiImplementation>(); app.MapGrpcService<UsersApiImplementation>();
using (IServiceScope scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
ApplicationDbContext appDbContext = services.GetRequiredService<ApplicationDbContext>(); var appDbContext = services.GetRequiredService<ApplicationDbContext>();
appDbContext.EnsureMigrationsApplied(); appDbContext.EnsureMigrationsApplied();
} }
app.Run(); app.Run();
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 // Needed for integration tests web application factory
public partial class Program { } public partial class Program
{
}

View File

@ -4,5 +4,13 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"Auth": {
"Issuer": "CleanArchitectureServer",
"Audience": "CleanArchitectureClient",
"Secret": "sD3v061gf8BxXgmxcHss"
} }
} }

View File

@ -8,5 +8,10 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" "DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"Auth": {
"Issuer": "CleanArchitectureServer",
"Audience": "CleanArchitectureClient",
"Secret": "sD3v061gf8BxXgmxcHss"
} }
} }

View File

@ -2,6 +2,7 @@ using System;
using System.Linq; using System.Linq;
using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Queries.Users.GetAll;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using MockQueryable.Moq; using MockQueryable.Moq;
using Moq; using Moq;
@ -10,17 +11,17 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users;
public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
{ {
public GetAllUsersTestFixture()
{
UserRepository = new Mock<IUserRepository>();
Handler = new GetAllUsersQueryHandler(UserRepository.Object);
}
private Mock<IUserRepository> UserRepository { get; } private Mock<IUserRepository> UserRepository { get; }
public GetAllUsersQueryHandler Handler { get; } public GetAllUsersQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid(); public Guid ExistingUserId { get; } = Guid.NewGuid();
public GetAllUsersTestFixture()
{
UserRepository = new();
Handler = new(UserRepository.Object);
}
public void SetupUserAsync() public void SetupUserAsync()
{ {
var user = new Mock<User>(() => var user = new Mock<User>(() =>
@ -28,7 +29,9 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
ExistingUserId, ExistingUserId,
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann")); "Mustermann",
"Password",
UserRole.User));
var query = new[] { user.Object }.AsQueryable().BuildMock(); var query = new[] { user.Object }.AsQueryable().BuildMock();
@ -44,7 +47,9 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
ExistingUserId, ExistingUserId,
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann")); "Mustermann",
"Password",
UserRole.User));
user.Object.Delete(); user.Object.Delete();

View File

@ -2,6 +2,7 @@ using System;
using System.Linq; using System.Linq;
using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Application.Queries.Users.GetUserById;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using MockQueryable.Moq; using MockQueryable.Moq;
using Moq; using Moq;
@ -10,17 +11,17 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users;
public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
{ {
public GetUserByIdTestFixture()
{
UserRepository = new Mock<IUserRepository>();
Handler = new GetUserByIdQueryHandler(UserRepository.Object, Bus.Object);
}
private Mock<IUserRepository> UserRepository { get; } private Mock<IUserRepository> UserRepository { get; }
public GetUserByIdQueryHandler Handler { get; } public GetUserByIdQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid(); public Guid ExistingUserId { get; } = Guid.NewGuid();
public GetUserByIdTestFixture()
{
UserRepository = new();
Handler = new(UserRepository.Object, Bus.Object);
}
public void SetupUserAsync() public void SetupUserAsync()
{ {
var user = new Mock<User>(() => var user = new Mock<User>(() =>
@ -28,7 +29,9 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
ExistingUserId, ExistingUserId,
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann")); "Mustermann",
"Password",
UserRole.User));
var query = new[] { user.Object }.AsQueryable().BuildMock(); var query = new[] { user.Object }.AsQueryable().BuildMock();
@ -44,7 +47,9 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
ExistingUserId, ExistingUserId,
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann")); "Mustermann",
"Password",
UserRole.User));
user.Object.Delete(); user.Object.Delete();

View File

@ -1,5 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Application.Queries.Users.GetAll;
using CleanArchitecture.Application.Tests.Fixtures.Queries.Users; using CleanArchitecture.Application.Tests.Fixtures.Queries.Users;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@ -16,14 +17,15 @@ public sealed class GetAllUsersQueryHandlerTests
_fixture.SetupUserAsync(); _fixture.SetupUserAsync();
var result = await _fixture.Handler.Handle( var result = await _fixture.Handler.Handle(
new(), new GetAllUsersQuery(),
default); default);
_fixture.VerifyNoDomainNotification(); _fixture.VerifyNoDomainNotification();
result.Should().NotBeNull(); var userViewModels = result.ToArray();
result.Should().ContainSingle(); userViewModels.Should().NotBeNull();
result.FirstOrDefault()!.Id.Should().Be(_fixture.ExistingUserId); userViewModels.Should().ContainSingle();
userViewModels.FirstOrDefault()!.Id.Should().Be(_fixture.ExistingUserId);
} }
[Fact] [Fact]
@ -32,7 +34,7 @@ public sealed class GetAllUsersQueryHandlerTests
_fixture.SetupDeletedUserAsync(); _fixture.SetupDeletedUserAsync();
var result = await _fixture.Handler.Handle( var result = await _fixture.Handler.Handle(
new(), new GetAllUsersQuery(),
default); default);
_fixture.VerifyNoDomainNotification(); _fixture.VerifyNoDomainNotification();

View File

@ -18,7 +18,7 @@ public sealed class GetUserByIdQueryHandlerTests
_fixture.SetupUserAsync(); _fixture.SetupUserAsync();
var result = await _fixture.Handler.Handle( var result = await _fixture.Handler.Handle(
new(_fixture.ExistingUserId, false), new GetUserByIdQuery(_fixture.ExistingUserId, false),
default); default);
_fixture.VerifyNoDomainNotification(); _fixture.VerifyNoDomainNotification();
@ -51,7 +51,7 @@ public sealed class GetUserByIdQueryHandlerTests
_fixture.SetupDeletedUserAsync(); _fixture.SetupDeletedUserAsync();
var result = await _fixture.Handler.Handle( var result = await _fixture.Handler.Handle(
new(_fixture.ExistingUserId, false), new GetUserByIdQuery(_fixture.ExistingUserId, false),
default); default);
_fixture.VerifyExistingNotification( _fixture.VerifyExistingNotification(

View File

@ -1,6 +1,6 @@
using System.Threading.Tasks;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Application.ViewModels.Users;
namespace CleanArchitecture.Application.Interfaces; namespace CleanArchitecture.Application.Interfaces;
@ -8,8 +8,11 @@ namespace CleanArchitecture.Application.Interfaces;
public interface IUserService public interface IUserService
{ {
public Task<UserViewModel?> GetUserByUserIdAsync(Guid userId, bool isDeleted); public Task<UserViewModel?> GetUserByUserIdAsync(Guid userId, bool isDeleted);
public Task<UserViewModel?> GetCurrentUserAsync();
public Task<IEnumerable<UserViewModel>> GetAllUsersAsync(); public Task<IEnumerable<UserViewModel>> GetAllUsersAsync();
public Task<Guid> CreateUserAsync(CreateUserViewModel user); public Task<Guid> CreateUserAsync(CreateUserViewModel user);
public Task UpdateUserAsync(UpdateUserViewModel user); public Task UpdateUserAsync(UpdateUserViewModel user);
public Task DeleteUserAsync(Guid userId); public Task DeleteUserAsync(Guid userId);
public Task ChangePasswordAsync(ChangePasswordViewModel viewModel);
public Task<string> LoginUserAsync(LoginUserViewModel viewModel);
} }

View File

@ -13,8 +13,8 @@ namespace CleanArchitecture.Application.Queries.Users.GetUserById;
public sealed class GetUserByIdQueryHandler : public sealed class GetUserByIdQueryHandler :
IRequestHandler<GetUserByIdQuery, UserViewModel?> IRequestHandler<GetUserByIdQuery, UserViewModel?>
{ {
private readonly IUserRepository _userRepository;
private readonly IMediatorHandler _bus; private readonly IMediatorHandler _bus;
private readonly IUserRepository _userRepository;
public GetUserByIdQueryHandler(IUserRepository userRepository, IMediatorHandler bus) public GetUserByIdQueryHandler(IUserRepository userRepository, IMediatorHandler bus)
{ {

View File

@ -5,8 +5,10 @@ using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Queries.Users.GetAll;
using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Application.Queries.Users.GetUserById;
using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Application.ViewModels.Users;
using CleanArchitecture.Domain.Commands.Users.ChangePassword;
using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Commands.Users.DeleteUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser;
using CleanArchitecture.Domain.Commands.Users.LoginUser;
using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
@ -15,10 +17,12 @@ namespace CleanArchitecture.Application.Services;
public sealed class UserService : IUserService public sealed class UserService : IUserService
{ {
private readonly IMediatorHandler _bus; private readonly IMediatorHandler _bus;
private readonly IUser _user;
public UserService(IMediatorHandler bus) public UserService(IMediatorHandler bus, IUser user)
{ {
_bus = bus; _bus = bus;
_user = user;
} }
public async Task<UserViewModel?> GetUserByUserIdAsync(Guid userId, bool isDeleted) public async Task<UserViewModel?> GetUserByUserIdAsync(Guid userId, bool isDeleted)
@ -26,6 +30,11 @@ public sealed class UserService : IUserService
return await _bus.QueryAsync(new GetUserByIdQuery(userId, isDeleted)); return await _bus.QueryAsync(new GetUserByIdQuery(userId, isDeleted));
} }
public async Task<UserViewModel?> GetCurrentUserAsync()
{
return await _bus.QueryAsync(new GetUserByIdQuery(_user.GetUserId(), false));
}
public async Task<IEnumerable<UserViewModel>> GetAllUsersAsync() public async Task<IEnumerable<UserViewModel>> GetAllUsersAsync()
{ {
return await _bus.QueryAsync(new GetAllUsersQuery()); return await _bus.QueryAsync(new GetAllUsersQuery());
@ -39,7 +48,8 @@ public sealed class UserService : IUserService
userId, userId,
user.Email, user.Email,
user.Surname, user.Surname,
user.GivenName)); user.GivenName,
user.Password));
return userId; return userId;
} }
@ -50,11 +60,22 @@ public sealed class UserService : IUserService
user.Id, user.Id,
user.Email, user.Email,
user.Surname, user.Surname,
user.GivenName)); user.GivenName,
user.Role));
} }
public async Task DeleteUserAsync(Guid userId) public async Task DeleteUserAsync(Guid userId)
{ {
await _bus.SendCommandAsync(new DeleteUserCommand(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<string> LoginUserAsync(LoginUserViewModel viewModel)
{
return await _bus.QueryAsync(new LoginUserCommand(viewModel.Email, viewModel.Password));
}
} }

View File

@ -0,0 +1,3 @@
namespace CleanArchitecture.Application.ViewModels.Users;
public sealed record ChangePasswordViewModel(string Password, string NewPassword);

View File

@ -0,0 +1,3 @@
namespace CleanArchitecture.Application.ViewModels.Users;
public sealed record LoginUserViewModel(string Email, string Password);

View File

@ -3,4 +3,5 @@ namespace CleanArchitecture.Application.ViewModels.Users;
public sealed record CreateUserViewModel( public sealed record CreateUserViewModel(
string Email, string Email,
string Surname, string Surname,
string GivenName); string GivenName,
string Password);

View File

@ -1,4 +1,5 @@
using System; using System;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Application.ViewModels.Users; namespace CleanArchitecture.Application.ViewModels.Users;
@ -6,4 +7,5 @@ public sealed record UpdateUserViewModel(
Guid Id, Guid Id,
string Email, string Email,
string Surname, string Surname,
string GivenName); string GivenName,
UserRole Role);

View File

@ -1,5 +1,6 @@
using System; using System;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Application.ViewModels.Users; namespace CleanArchitecture.Application.ViewModels.Users;
@ -9,6 +10,7 @@ public sealed class UserViewModel
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string GivenName { get; set; } = string.Empty; public string GivenName { get; set; } = string.Empty;
public string Surname { get; set; } = string.Empty; public string Surname { get; set; } = string.Empty;
public UserRole Role { get; set; }
public static UserViewModel FromUser(User user) public static UserViewModel FromUser(User user)
{ {
@ -17,7 +19,8 @@ public sealed class UserViewModel
Id = user.Id, Id = user.Id,
Email = user.Email, Email = user.Email,
GivenName = user.GivenName, GivenName = user.GivenName,
Surname = user.Surname Surname = user.Surname,
Role = user.Role
}; };
} }
} }

View File

@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="FluentAssertions" Version="6.10.0"/> <PackageReference Include="FluentAssertions" Version="6.10.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="Moq" Version="4.18.4"/> <PackageReference Include="Moq" Version="4.18.4"/>

View File

@ -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<PasswordChangedEvent>(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<UserUpdatedEvent>()
.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<UserUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.UserPasswordIncorrect,
"The password is incorrect");
}
}

View File

@ -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<IUserRepository>();
CommandHandler = new ChangePasswordCommandHandler(
Bus.Object,
UnitOfWork.Object,
NotificationHandler.Object,
UserRepository.Object,
User.Object);
}
public ChangePasswordCommandHandler CommandHandler { get; }
private Mock<IUserRepository> 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<Guid>(y => y == user.Id)))
.ReturnsAsync(user);
return user;
}
public Guid SetupMissingUser()
{
var id = Guid.NewGuid();
User.Setup(x => x.GetUserId()).Returns(id);
return id;
}
}

View File

@ -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<ChangePasswordCommand, ChangePasswordCommandValidation>
{
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<string>
{
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");
}
}

View File

@ -19,7 +19,8 @@ public sealed class CreateUserCommandHandlerTests
Guid.NewGuid(), Guid.NewGuid(),
"test@email.com", "test@email.com",
"Test", "Test",
"Email"); "Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait(); _fixture.CommandHandler.Handle(command, default).Wait();
@ -38,7 +39,8 @@ public sealed class CreateUserCommandHandlerTests
user.Id, user.Id,
"test@email.com", "test@email.com",
"Test", "Test",
"Email"); "Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait(); _fixture.CommandHandler.Handle(command, default).Wait();

View File

@ -1,5 +1,6 @@
using System; using System;
using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using Moq; using Moq;
@ -7,27 +8,29 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.CreateUser;
public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase
{ {
public CreateUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public CreateUserCommandTestFixture() public CreateUserCommandTestFixture()
{ {
UserRepository = new Mock<IUserRepository>(); UserRepository = new Mock<IUserRepository>();
CommandHandler = new( CommandHandler = new CreateUserCommandHandler(
Bus.Object, Bus.Object,
UnitOfWork.Object, UnitOfWork.Object,
NotificationHandler.Object, NotificationHandler.Object,
UserRepository.Object); UserRepository.Object);
} }
public CreateUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public Entities.User SetupUser() public Entities.User SetupUser()
{ {
var user = new Entities.User( var user = new Entities.User(
Guid.NewGuid(), Guid.NewGuid(),
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann"); "Mustermann",
"Password",
UserRole.User);
UserRepository UserRepository
.Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id))) .Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id)))

View File

@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using Xunit; using Xunit;
@ -23,7 +25,7 @@ public sealed class CreateUserCommandValidationTests :
[Fact] [Fact]
public void Should_Be_Invalid_For_Empty_User_Id() public void Should_Be_Invalid_For_Empty_User_Id()
{ {
var command = CreateTestCommand(userId: Guid.Empty); var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError( ShouldHaveSingleError(
command, command,
@ -108,14 +110,84 @@ public sealed class CreateUserCommandValidationTests :
"Given name may not be longer than 100 characters"); "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<string>
{
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, Guid? userId = null,
string? email = null, string? email = null,
string? surName = null, string? surName = null,
string? givenName = null) => string? givenName = null,
new ( string? password = null)
{
return new(
userId ?? Guid.NewGuid(), userId ?? Guid.NewGuid(),
email ?? "test@email.com", email ?? "test@email.com",
surName ?? "test", surName ?? "test",
givenName ?? "email"); givenName ?? "email",
password ?? "Po=PF]PC6t.?8?ks)A6W");
}
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using CleanArchitecture.Domain.Commands.Users.DeleteUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using Moq; using Moq;
@ -7,27 +8,30 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.DeleteUser;
public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase
{ {
public DeleteUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public DeleteUserCommandTestFixture() public DeleteUserCommandTestFixture()
{ {
UserRepository = new Mock<IUserRepository>(); UserRepository = new Mock<IUserRepository>();
CommandHandler = new ( CommandHandler = new DeleteUserCommandHandler(
Bus.Object, Bus.Object,
UnitOfWork.Object, UnitOfWork.Object,
NotificationHandler.Object, NotificationHandler.Object,
UserRepository.Object); UserRepository.Object,
User.Object);
} }
public DeleteUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public Entities.User SetupUser() public Entities.User SetupUser()
{ {
var user = new Entities.User( var user = new Entities.User(
Guid.NewGuid(), Guid.NewGuid(),
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann"); "Mustermann",
"Password",
UserRole.User);
UserRepository UserRepository
.Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id))) .Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id)))

View File

@ -23,7 +23,7 @@ public sealed class DeleteUserCommandValidationTests :
[Fact] [Fact]
public void Should_Be_Invalid_For_Empty_User_Id() public void Should_Be_Invalid_For_Empty_User_Id()
{ {
var command = CreateTestCommand(userId: Guid.Empty); var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError( ShouldHaveSingleError(
command, command,
@ -31,6 +31,8 @@ public sealed class DeleteUserCommandValidationTests :
"User id may not be empty"); "User id may not be empty");
} }
private DeleteUserCommand CreateTestCommand(Guid? userId = null) => private static DeleteUserCommand CreateTestCommand(Guid? userId = null)
new (userId ?? Guid.NewGuid()); {
return new(userId ?? Guid.NewGuid());
}
} }

View File

@ -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();
}
}

View File

@ -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<IUserRepository>();
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<IUserRepository> UserRepository { get; set; }
public IOptions<TokenSettings> 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<string>(y => y == user.Email)))
.ReturnsAsync(user);
return user;
}
}

View File

@ -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<LoginUserCommand, LoginUserCommandValidation>
{
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<string>
{
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");
}
}

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using Xunit; using Xunit;
@ -20,7 +21,8 @@ public sealed class UpdateUserCommandHandlerTests
user.Id, user.Id,
"test@email.com", "test@email.com",
"Test", "Test",
"Email"); "Email",
UserRole.User);
await _fixture.CommandHandler.Handle(command, default); await _fixture.CommandHandler.Handle(command, default);
@ -39,7 +41,8 @@ public sealed class UpdateUserCommandHandlerTests
Guid.NewGuid(), Guid.NewGuid(),
"test@email.com", "test@email.com",
"Test", "Test",
"Email"); "Email",
UserRole.User);
await _fixture.CommandHandler.Handle(command, default); await _fixture.CommandHandler.Handle(command, default);

View File

@ -1,5 +1,6 @@
using System; using System;
using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using Moq; using Moq;
@ -7,27 +8,30 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.UpdateUser;
public sealed class UpdateUserCommandTestFixture : CommandHandlerFixtureBase public sealed class UpdateUserCommandTestFixture : CommandHandlerFixtureBase
{ {
public UpdateUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public UpdateUserCommandTestFixture() public UpdateUserCommandTestFixture()
{ {
UserRepository = new Mock<IUserRepository>(); UserRepository = new Mock<IUserRepository>();
CommandHandler = new( CommandHandler = new UpdateUserCommandHandler(
Bus.Object, Bus.Object,
UnitOfWork.Object, UnitOfWork.Object,
NotificationHandler.Object, NotificationHandler.Object,
UserRepository.Object); UserRepository.Object,
User.Object);
} }
public UpdateUserCommandHandler CommandHandler { get; }
private Mock<IUserRepository> UserRepository { get; }
public Entities.User SetupUser() public Entities.User SetupUser()
{ {
var user = new Entities.User( var user = new Entities.User(
Guid.NewGuid(), Guid.NewGuid(),
"max@mustermann.com", "max@mustermann.com",
"Max", "Max",
"Mustermann"); "Mustermann",
"Password",
UserRole.User);
UserRepository UserRepository
.Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id))) .Setup(x => x.GetByIdAsync(It.Is<Guid>(y => y == user.Id)))

View File

@ -1,5 +1,6 @@
using System; using System;
using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using Xunit; using Xunit;
@ -23,7 +24,7 @@ public sealed class UpdateUserCommandValidationTests :
[Fact] [Fact]
public void Should_Be_Invalid_For_Empty_User_Id() public void Should_Be_Invalid_For_Empty_User_Id()
{ {
var command = CreateTestCommand(userId: Guid.Empty); var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError( ShouldHaveSingleError(
command, command,
@ -108,14 +109,18 @@ public sealed class UpdateUserCommandValidationTests :
"Given name may not be longer than 100 characters"); "Given name may not be longer than 100 characters");
} }
private UpdateUserCommand CreateTestCommand( private static UpdateUserCommand CreateTestCommand(
Guid? userId = null, Guid? userId = null,
string? email = null, string? email = null,
string? surName = null, string? surName = null,
string? givenName = null) => string? givenName = null,
new ( UserRole? role = null)
{
return new(
userId ?? Guid.NewGuid(), userId ?? Guid.NewGuid(),
email ?? "test@email.com", email ?? "test@email.com",
surName ?? "test", surName ?? "test",
givenName ?? "email"); givenName ?? "email",
role ?? UserRole.User);
}
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Linq.Expressions; using System.Linq.Expressions;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Notifications; using CleanArchitecture.Domain.Notifications;
using Moq; using Moq;
@ -8,19 +9,24 @@ namespace CleanArchitecture.Domain.Tests;
public class CommandHandlerFixtureBase public class CommandHandlerFixtureBase
{ {
protected Mock<IMediatorHandler> Bus { get; }
protected Mock<IUnitOfWork> UnitOfWork { get; }
protected Mock<DomainNotificationHandler> NotificationHandler { get; }
protected CommandHandlerFixtureBase() protected CommandHandlerFixtureBase()
{ {
Bus = new Mock<IMediatorHandler>(); Bus = new Mock<IMediatorHandler>();
UnitOfWork = new Mock<IUnitOfWork>(); UnitOfWork = new Mock<IUnitOfWork>();
NotificationHandler = new Mock<DomainNotificationHandler>(); NotificationHandler = new Mock<DomainNotificationHandler>();
User = new Mock<IUser>();
User.Setup(x => x.GetUserId()).Returns(Guid.NewGuid());
User.Setup(x => x.GetUserRole()).Returns(UserRole.Admin);
UnitOfWork.Setup(unit => unit.CommitAsync()).ReturnsAsync(true); UnitOfWork.Setup(unit => unit.CommitAsync()).ReturnsAsync(true);
} }
protected Mock<IMediatorHandler> Bus { get; }
protected Mock<IUnitOfWork> UnitOfWork { get; }
protected Mock<DomainNotificationHandler> NotificationHandler { get; }
protected Mock<IUser> User { get; }
public CommandHandlerFixtureBase VerifyExistingNotification(string errorCode, string message) public CommandHandlerFixtureBase VerifyExistingNotification(string errorCode, string message)
{ {
Bus.Verify( Bus.Verify(

View File

@ -70,4 +70,22 @@ public class ValidationTestBase<TCommand, TValidation>
.Be(1); .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);
}
}
} }

View File

@ -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");
}
}

View File

@ -6,8 +6,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="FluentValidation" Version="11.5.1"/> <PackageReference Include="FluentValidation" Version="11.5.1"/>
<PackageReference Include="MediatR" Version="12.0.1"/> <PackageReference Include="MediatR" Version="12.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0"/>
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.27.0"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -6,11 +6,6 @@ namespace CleanArchitecture.Domain.Commands;
public abstract class CommandBase : IRequest 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) protected CommandBase(Guid aggregateId)
{ {
MessageType = GetType().Name; MessageType = GetType().Name;
@ -18,5 +13,10 @@ public abstract class CommandBase : IRequest
AggregateId = aggregateId; AggregateId = aggregateId;
} }
public Guid AggregateId { get; }
public string MessageType { get; }
public DateTime Timestamp { get; }
public ValidationResult? ValidationResult { get; protected set; }
public abstract bool IsValid(); public abstract bool IsValid();
} }

View File

@ -9,21 +9,21 @@ namespace CleanArchitecture.Domain.Commands;
public abstract class CommandHandlerBase public abstract class CommandHandlerBase
{ {
protected readonly IMediatorHandler _bus; protected readonly IMediatorHandler Bus;
private readonly IUnitOfWork _unitOfWork;
private readonly DomainNotificationHandler _notifications; private readonly DomainNotificationHandler _notifications;
private readonly IUnitOfWork _unitOfWork;
protected CommandHandlerBase( protected CommandHandlerBase(
IMediatorHandler bus, IMediatorHandler bus,
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications) INotificationHandler<DomainNotification> notifications)
{ {
_bus = bus; Bus = bus;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_notifications = (DomainNotificationHandler)notifications; _notifications = (DomainNotificationHandler)notifications;
} }
public async Task<bool> CommitAsync() protected async Task<bool> CommitAsync()
{ {
if (_notifications.HasNotifications()) if (_notifications.HasNotifications())
{ {
@ -35,7 +35,7 @@ public abstract class CommandHandlerBase
return true; return true;
} }
await _bus.RaiseEventAsync( await Bus.RaiseEventAsync(
new DomainNotification( new DomainNotification(
"Commit", "Commit",
"Problem occured while saving the data. Please try again.", "Problem occured while saving the data. Please try again.",
@ -46,13 +46,13 @@ public abstract class CommandHandlerBase
protected async Task NotifyAsync(string key, string message, string code) protected async Task NotifyAsync(string key, string message, string code)
{ {
await _bus.RaiseEventAsync( await Bus.RaiseEventAsync(
new DomainNotification(key, message, code)); new DomainNotification(key, message, code));
} }
protected async Task NotifyAsync(DomainNotification notification) protected async Task NotifyAsync(DomainNotification notification)
{ {
await _bus.RaiseEventAsync(notification); await Bus.RaiseEventAsync(notification);
} }
protected async ValueTask<bool> TestValidityAsync(CommandBase command) protected async ValueTask<bool> TestValidityAsync(CommandBase command)

View File

@ -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;
}
}

View File

@ -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<ChangePasswordCommand>
{
private readonly IUser _user;
private readonly IUserRepository _userRepository;
public ChangePasswordCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> 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));
}
}
}

View File

@ -0,0 +1,25 @@
using CleanArchitecture.Domain.Extensions.Validation;
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Users.ChangePassword;
public sealed class ChangePasswordCommandValidation : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidation()
{
AddRuleForPassword();
AddRuleForNewPassword();
}
private void AddRuleForPassword()
{
RuleFor(cmd => cmd.Password)
.Password();
}
private void AddRuleForNewPassword()
{
RuleFor(cmd => cmd.NewPassword)
.Password();
}
}

View File

@ -6,23 +6,26 @@ public sealed class CreateUserCommand : CommandBase
{ {
private readonly CreateUserCommandValidation _validation = new(); private readonly CreateUserCommandValidation _validation = new();
public Guid UserId { get; }
public string Email { get; }
public string Surname { get; }
public string GivenName { get; }
public CreateUserCommand( public CreateUserCommand(
Guid userId, Guid userId,
string email, string email,
string surname, string surname,
string givenName) : base(userId) string givenName,
string password) : base(userId)
{ {
UserId = userId; UserId = userId;
Email = email; Email = email;
Surname = surname; Surname = surname;
GivenName = givenName; 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() public override bool IsValid()
{ {
ValidationResult = _validation.Validate(this); ValidationResult = _validation.Validate(this);

View File

@ -1,12 +1,14 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications; using CleanArchitecture.Domain.Notifications;
using MediatR; using MediatR;
using BC = BCrypt.Net.BCrypt;
namespace CleanArchitecture.Domain.Commands.Users.CreateUser; namespace CleanArchitecture.Domain.Commands.Users.CreateUser;
@ -35,7 +37,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
if (existingUser != null) if (existingUser != null)
{ {
await _bus.RaiseEventAsync( await Bus.RaiseEventAsync(
new DomainNotification( new DomainNotification(
request.MessageType, request.MessageType,
$"There is already a User with Id {request.UserId}", $"There is already a User with Id {request.UserId}",
@ -43,17 +45,33 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
return; 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( var user = new User(
request.UserId, request.UserId,
request.Email, request.Email,
request.Surname, request.Surname,
request.GivenName); request.GivenName,
passwordHash,
UserRole.User);
_userRepository.Add(user); _userRepository.Add(user);
if (await CommitAsync()) if (await CommitAsync())
{ {
await _bus.RaiseEventAsync(new UserCreatedEvent(user.Id)); await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id));
} }
} }
} }

View File

@ -1,4 +1,5 @@
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Extensions.Validation;
using FluentValidation; using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Users.CreateUser; namespace CleanArchitecture.Domain.Commands.Users.CreateUser;
@ -11,6 +12,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
AddRuleForEmail(); AddRuleForEmail();
AddRuleForSurname(); AddRuleForSurname();
AddRuleForGivenName(); AddRuleForGivenName();
AddRuleForPassword();
} }
private void AddRuleForId() private void AddRuleForId()
@ -53,4 +55,10 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
.WithErrorCode(DomainErrorCodes.UserGivenNameExceedsMaxLength) .WithErrorCode(DomainErrorCodes.UserGivenNameExceedsMaxLength)
.WithMessage("Given name may not be longer than 100 characters"); .WithMessage("Given name may not be longer than 100 characters");
} }
private void AddRuleForPassword()
{
RuleFor(cmd => cmd.Password)
.Password();
}
} }

View File

@ -6,13 +6,13 @@ public sealed class DeleteUserCommand : CommandBase
{ {
private readonly DeleteUserCommandValidation _validation = new(); private readonly DeleteUserCommandValidation _validation = new();
public Guid UserId { get; }
public DeleteUserCommand(Guid userId) : base(userId) public DeleteUserCommand(Guid userId) : base(userId)
{ {
UserId = userId; UserId = userId;
} }
public Guid UserId { get; }
public override bool IsValid() public override bool IsValid()
{ {
ValidationResult = _validation.Validate(this); ValidationResult = _validation.Validate(this);

View File

@ -1,5 +1,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
@ -12,15 +13,18 @@ namespace CleanArchitecture.Domain.Commands.Users.DeleteUser;
public sealed class DeleteUserCommandHandler : CommandHandlerBase, public sealed class DeleteUserCommandHandler : CommandHandlerBase,
IRequestHandler<DeleteUserCommand> IRequestHandler<DeleteUserCommand>
{ {
private readonly IUser _user;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
public DeleteUserCommandHandler( public DeleteUserCommandHandler(
IMediatorHandler bus, IMediatorHandler bus,
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications, INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository) : base(bus, unitOfWork, notifications) IUserRepository userRepository,
IUser user) : base(bus, unitOfWork, notifications)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_user = user;
} }
public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken) public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken)
@ -43,11 +47,22 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase,
return; 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); _userRepository.Remove(user);
if (await CommitAsync()) if (await CommitAsync())
{ {
await _bus.RaiseEventAsync(new UserDeletedEvent(request.UserId)); await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId));
} }
} }
} }

View File

@ -0,0 +1,28 @@
using System;
using MediatR;
namespace CleanArchitecture.Domain.Commands.Users.LoginUser;
public sealed class LoginUserCommand : CommandBase,
IRequest<string>
{
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;
}
}

View File

@ -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<LoginUserCommand, string>
{
private const double ExpiryDurationMinutes = 30;
private readonly TokenSettings _tokenSettings;
private readonly IUserRepository _userRepository;
public LoginUserCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository,
IOptions<TokenSettings> tokenSettings) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_tokenSettings = tokenSettings.Value;
}
public async Task<string> 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);
}
}

View File

@ -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<LoginUserCommand>
{
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();
}
}

View File

@ -1,4 +1,5 @@
using System; using System;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Domain.Commands.Users.UpdateUser; namespace CleanArchitecture.Domain.Commands.Users.UpdateUser;
@ -6,23 +7,26 @@ public sealed class UpdateUserCommand : CommandBase
{ {
private readonly UpdateUserCommandValidation _validation = new(); private readonly UpdateUserCommandValidation _validation = new();
public Guid UserId { get; }
public string Email { get; }
public string Surname { get; }
public string GivenName { get; }
public UpdateUserCommand( public UpdateUserCommand(
Guid userId, Guid userId,
string email, string email,
string surname, string surname,
string givenName) : base(userId) string givenName,
UserRole role) : base(userId)
{ {
UserId = userId; UserId = userId;
Email = email; Email = email;
Surname = surname; Surname = surname;
GivenName = givenName; 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() public override bool IsValid()
{ {
ValidationResult = _validation.Validate(this); ValidationResult = _validation.Validate(this);

View File

@ -1,5 +1,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
@ -12,15 +13,18 @@ namespace CleanArchitecture.Domain.Commands.Users.UpdateUser;
public sealed class UpdateUserCommandHandler : CommandHandlerBase, public sealed class UpdateUserCommandHandler : CommandHandlerBase,
IRequestHandler<UpdateUserCommand> IRequestHandler<UpdateUserCommand>
{ {
private readonly IUser _user;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
public UpdateUserCommandHandler( public UpdateUserCommandHandler(
IMediatorHandler bus, IMediatorHandler bus,
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications, INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository) : base(bus, unitOfWork, notifications) IUserRepository userRepository,
IUser user) : base(bus, unitOfWork, notifications)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_user = user;
} }
public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
@ -34,7 +38,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (user == null) if (user == null)
{ {
await _bus.RaiseEventAsync( await Bus.RaiseEventAsync(
new DomainNotification( new DomainNotification(
request.MessageType, request.MessageType,
$"There is no User with Id {request.UserId}", $"There is no User with Id {request.UserId}",
@ -42,6 +46,22 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
return; 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.SetEmail(request.Email);
user.SetSurname(request.Surname); user.SetSurname(request.Surname);
user.SetGivenName(request.GivenName); user.SetGivenName(request.GivenName);
@ -50,7 +70,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (await CommitAsync()) if (await CommitAsync())
{ {
await _bus.RaiseEventAsync(new UserUpdatedEvent(user.Id)); await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id));
} }
} }
} }

View File

@ -11,6 +11,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
AddRuleForEmail(); AddRuleForEmail();
AddRuleForSurname(); AddRuleForSurname();
AddRuleForGivenName(); AddRuleForGivenName();
AddRuleForRole();
} }
private void AddRuleForId() private void AddRuleForId()
@ -53,4 +54,12 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
.WithErrorCode(DomainErrorCodes.UserGivenNameExceedsMaxLength) .WithErrorCode(DomainErrorCodes.UserGivenNameExceedsMaxLength)
.WithMessage("Given name may not be longer than 100 characters"); .WithMessage("Given name may not be longer than 100 characters");
} }
private void AddRuleForRole()
{
RuleFor(cmd => cmd.Role)
.IsInEnum()
.WithErrorCode(DomainErrorCodes.UserInvalidRole)
.WithMessage("Role is not a valid role");
}
} }

View File

@ -31,31 +31,4 @@ public abstract class Entity
{ {
Deleted = false; 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 + "]";
}
} }

View File

@ -1,27 +1,34 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Domain.Entities;
public class User : Entity 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( public User(
Guid id, Guid id,
string email, string email,
string surname, string surname,
string givenName) : base(id) string givenName,
string password,
UserRole role) : base(id)
{ {
Email = email; Email = email;
GivenName = givenName; GivenName = givenName;
Surname = surname; 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))] [MemberNotNull(nameof(Email))]
public void SetEmail(string email) public void SetEmail(string email)
{ {
@ -72,4 +79,26 @@ public class User : Entity
Surname = surname; 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;
}
} }

View File

@ -0,0 +1,7 @@
namespace CleanArchitecture.Domain.Enums;
public enum UserRole
{
Admin,
User
}

View File

@ -10,7 +10,18 @@ public static class DomainErrorCodes
public const string UserSurnameExceedsMaxLength = "USER_SURNAME_EXCEEDS_MAX_LENGTH"; public const string UserSurnameExceedsMaxLength = "USER_SURNAME_EXCEEDS_MAX_LENGTH";
public const string UserGivenNameExceedsMaxLength = "USER_GIVEN_NAME_EXCEEDS_MAX_LENGTH"; public const string UserGivenNameExceedsMaxLength = "USER_GIVEN_NAME_EXCEEDS_MAX_LENGTH";
public const string UserInvalidEmail = "USER_INVALID_EMAIL"; 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 // User
public const string UserAlreadyExists = "USER_ALREADY_EXISTS"; public const string UserAlreadyExists = "USER_ALREADY_EXISTS";
public const string UserPasswordIncorrect = "USER_PASSWORD_INCORRECT";
} }

View File

@ -4,4 +4,5 @@ public static class ErrorCodes
{ {
public const string CommitFailed = "COMMIT_FAILED"; public const string CommitFailed = "COMMIT_FAILED";
public const string ObjectNotFound = "OBJECT_NOT_FOUND"; public const string ObjectNotFound = "OBJECT_NOT_FOUND";
public const string InsufficientPermissions = "UNAUTHORIZED";
} }

View File

@ -8,9 +8,10 @@ namespace CleanArchitecture.Domain.EventHandler;
public sealed class UserEventHandler : public sealed class UserEventHandler :
INotificationHandler<UserDeletedEvent>, INotificationHandler<UserDeletedEvent>,
INotificationHandler<UserCreatedEvent>, INotificationHandler<UserCreatedEvent>,
INotificationHandler<UserUpdatedEvent> INotificationHandler<UserUpdatedEvent>,
INotificationHandler<PasswordChangedEvent>
{ {
public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) public Task Handle(PasswordChangedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -20,6 +21,11 @@ public sealed class UserEventHandler :
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken) public Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;

View File

@ -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; }
}

View File

@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserCreatedEvent : DomainEvent public sealed class UserCreatedEvent : DomainEvent
{ {
public Guid UserId { get; }
public UserCreatedEvent(Guid userId) : base(userId) public UserCreatedEvent(Guid userId) : base(userId)
{ {
UserId = userId; UserId = userId;
} }
public Guid UserId { get; }
} }

View File

@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserDeletedEvent : DomainEvent public sealed class UserDeletedEvent : DomainEvent
{ {
public Guid UserId { get; }
public UserDeletedEvent(Guid userId) : base(userId) public UserDeletedEvent(Guid userId) : base(userId)
{ {
UserId = userId; UserId = userId;
} }
public Guid UserId { get; }
} }

View File

@ -4,10 +4,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserUpdatedEvent : DomainEvent public sealed class UserUpdatedEvent : DomainEvent
{ {
public Guid UserId { get; }
public UserUpdatedEvent(Guid userId) : base(userId) public UserUpdatedEvent(Guid userId) : base(userId)
{ {
UserId = userId; UserId = userId;
} }
public Guid UserId { get; }
} }

View File

@ -1,8 +1,11 @@
using CleanArchitecture.Domain.Commands.Users.ChangePassword;
using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Commands.Users.DeleteUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser;
using CleanArchitecture.Domain.Commands.Users.LoginUser;
using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.EventHandler; using CleanArchitecture.Domain.EventHandler;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using CleanArchitecture.Domain.Interfaces;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -16,6 +19,9 @@ public static class ServiceCollectionExtension
services.AddScoped<IRequestHandler<CreateUserCommand>, CreateUserCommandHandler>(); services.AddScoped<IRequestHandler<CreateUserCommand>, CreateUserCommandHandler>();
services.AddScoped<IRequestHandler<UpdateUserCommand>, UpdateUserCommandHandler>(); services.AddScoped<IRequestHandler<UpdateUserCommand>, UpdateUserCommandHandler>();
services.AddScoped<IRequestHandler<DeleteUserCommand>, DeleteUserCommandHandler>(); services.AddScoped<IRequestHandler<DeleteUserCommand>, DeleteUserCommandHandler>();
services.AddScoped<IRequestHandler<ChangePasswordCommand>, ChangePasswordCommandHandler>();
services.AddScoped<IRequestHandler<LoginUserCommand, string>, LoginUserCommandHandler>();
return services; return services;
} }
@ -26,6 +32,15 @@ public static class ServiceCollectionExtension
services.AddScoped<INotificationHandler<UserCreatedEvent>, UserEventHandler>(); services.AddScoped<INotificationHandler<UserCreatedEvent>, UserEventHandler>();
services.AddScoped<INotificationHandler<UserUpdatedEvent>, UserEventHandler>(); services.AddScoped<INotificationHandler<UserUpdatedEvent>, UserEventHandler>();
services.AddScoped<INotificationHandler<UserDeletedEvent>, UserEventHandler>(); services.AddScoped<INotificationHandler<UserDeletedEvent>, UserEventHandler>();
services.AddScoped<INotificationHandler<PasswordChangedEvent>, UserEventHandler>();
return services;
}
public static IServiceCollection AddApiUser(this IServiceCollection services)
{
// User
services.AddScoped<IUser, ApiUser>();
return services; return services;
} }

View File

@ -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<T, string> StringMustBeBase64<T>(this IRuleBuilder<T, string> 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<T, string> Password<T>(
this IRuleBuilder<T, string> 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;
}
}

View File

@ -0,0 +1,11 @@
using System;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Domain.Interfaces;
public interface IUser
{
string Name { get; }
Guid GetUserId();
UserRole GetUserRole();
}

View File

@ -4,11 +4,6 @@ namespace CleanArchitecture.Domain.Notifications;
public sealed class DomainNotification : DomainEvent 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( public DomainNotification(
string key, string key,
string value, string value,
@ -23,4 +18,9 @@ public sealed class DomainNotification : DomainEvent
Data = data; Data = data;
} }
public string Key { get; }
public string Value { get; }
public string Code { get; }
public object? Data { get; set; }
} }

View File

@ -15,11 +15,6 @@ public class DomainNotificationHandler : INotificationHandler<DomainNotification
_notifications = new List<DomainNotification>(); _notifications = new List<DomainNotification>();
} }
public virtual List<DomainNotification> GetNotifications()
{
return _notifications;
}
public Task Handle(DomainNotification notification, CancellationToken cancellationToken = default) public Task Handle(DomainNotification notification, CancellationToken cancellationToken = default)
{ {
_notifications.Add(notification); _notifications.Add(notification);
@ -27,6 +22,11 @@ public class DomainNotificationHandler : INotificationHandler<DomainNotification
return Task.CompletedTask; return Task.CompletedTask;
} }
public virtual List<DomainNotification> GetNotifications()
{
return _notifications;
}
public virtual bool HasNotifications() public virtual bool HasNotifications()
{ {
return GetNotifications().Any(); return GetNotifications().Any();

View File

@ -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!;
}

View File

@ -16,9 +16,9 @@ public sealed class DomainNotificationHandlerTests
[Fact] [Fact]
public void Should_Handle_DomainNotification() public void Should_Handle_DomainNotification()
{ {
string key = "Key"; const string key = "Key";
string value = "Value"; const string value = "Value";
string code = "Code"; const string code = "Code";
var domainNotification = new DomainNotification(key, value, code); var domainNotification = new DomainNotification(key, value, code);
var domainNotificationHandler = new DomainNotificationHandler(); var domainNotificationHandler = new DomainNotificationHandler();
@ -29,9 +29,9 @@ public sealed class DomainNotificationHandlerTests
[Fact] [Fact]
public void Should_Handle_DomainNotification_Overload() public void Should_Handle_DomainNotification_Overload()
{ {
string key = "Key"; const string key = "Key";
string value = "Value"; const string value = "Value";
string code = "Code"; const string code = "Code";
var domainNotification = new DomainNotification(key, value, code); var domainNotification = new DomainNotification(key, value, code);
var domainNotificationHandler = new DomainNotificationHandler(); var domainNotificationHandler = new DomainNotificationHandler();
@ -42,9 +42,9 @@ public sealed class DomainNotificationHandlerTests
[Fact] [Fact]
public void DomainNotification_HasNotifications_After_Handling_One() public void DomainNotification_HasNotifications_After_Handling_One()
{ {
string key = "Key"; const string key = "Key";
string value = "Value"; const string value = "Value";
string code = "Code"; const string code = "Code";
var domainNotification = new DomainNotification(key, value, code); var domainNotification = new DomainNotification(key, value, code);
var domainNotificationHandler = new DomainNotificationHandler(); var domainNotificationHandler = new DomainNotificationHandler();

View File

@ -10,9 +10,9 @@ public sealed class DomainNotificationTests
[Fact] [Fact]
public void Should_Create_DomainNotification_Instance() public void Should_Create_DomainNotification_Instance()
{ {
string key = "Key"; const string key = "Key";
string value = "Value"; const string value = "Value";
string code = "Code"; const string code = "Code";
var domainNotification = new DomainNotification( var domainNotification = new DomainNotification(
key, value, code); key, value, code);
@ -26,9 +26,9 @@ public sealed class DomainNotificationTests
[Fact] [Fact]
public void Should_Create_DomainNotification_Overload_Instance() public void Should_Create_DomainNotification_Overload_Instance()
{ {
string key = "Key"; const string key = "Key";
string value = "Value"; const string value = "Value";
string code = "Code"; const string code = "Code";
var domainNotification = new DomainNotification( var domainNotification = new DomainNotification(
key, value, code); key, value, code);

View File

@ -19,9 +19,9 @@ public sealed class InMemoryBusTests
var inMemoryBus = new InMemoryBus(mediator.Object); var inMemoryBus = new InMemoryBus(mediator.Object);
var key = "Key"; const string key = "Key";
var value = "Value"; const string value = "Value";
var code = "Code"; const string code = "Code";
var domainEvent = new DomainNotification(key, value, code); var domainEvent = new DomainNotification(key, value, code);

View File

@ -1,4 +1,6 @@
using System;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -22,5 +24,19 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
.Property(user => user.Surname) .Property(user => user.Surname)
.IsRequired() .IsRequired()
.HasMaxLength(100); .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));
} }
} }

View File

@ -6,12 +6,12 @@ namespace CleanArchitecture.Infrastructure.Database;
public class ApplicationDbContext : DbContext public class ApplicationDbContext : DbContext
{ {
public DbSet<User> Users { get; set; } = null!;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{ {
} }
public DbSet<User> Users { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
builder.ApplyConfiguration(new UserConfiguration()); builder.ApplyConfiguration(new UserConfiguration());

View File

@ -0,0 +1,82 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("Deleted")
.HasColumnType("bit");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("nvarchar(320)");
b.Property<string>("GivenName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CleanArchitecture.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserRoleAndPassword : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Password",
table: "Users",
type: "nvarchar(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
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" });
}
/// <inheritdoc />
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");
}
}
}

View File

@ -17,7 +17,7 @@ namespace CleanArchitecture.Infrastructure.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "7.0.3") .HasAnnotation("ProductVersion", "7.0.4")
.HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true) .HasAnnotation("Proxies:LazyLoading", true)
@ -44,6 +44,14 @@ namespace CleanArchitecture.Infrastructure.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("nvarchar(100)"); .HasColumnType("nvarchar(100)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<string>("Surname") b.Property<string>("Surname")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -52,6 +60,18 @@ namespace CleanArchitecture.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Users"); 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 #pragma warning restore 612, 618
} }

View File

@ -50,19 +50,6 @@ public class BaseRepository<TEntity> : IRepository<TEntity> where TEntity : Enti
return await DbSet.FindAsync(id); 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) public virtual void Update(TEntity entity)
{ {
DbSet.Update(entity); DbSet.Update(entity);
@ -86,4 +73,16 @@ public class BaseRepository<TEntity> : IRepository<TEntity> where TEntity : Enti
} }
} }
public int SaveChanges()
{
return _dbContext.SaveChanges();
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_dbContext.Dispose();
}
}
} }

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Application.ViewModels.Users;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.IntegrationTests.Extensions; using CleanArchitecture.IntegrationTests.Extensions;
using CleanArchitecture.IntegrationTests.Fixtures; using CleanArchitecture.IntegrationTests.Fixtures;
using FluentAssertions; using FluentAssertions;
@ -23,28 +24,17 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
_fixture = fixture; _fixture = fixture;
} }
[Fact, Priority(0)] [Fact]
public async Task Should_Get_No_User() [Priority(0)]
{
var response = await _fixture.ServerClient.GetAsync("user");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var message = await response.Content.ReadAsJsonAsync<IEnumerable<UserViewModel>>();
message?.Data.Should().NotBeNull();
var content = message!.Data!;
content.Should().BeNullOrEmpty();
}
[Fact, Priority(5)]
public async Task Should_Create_User() 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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -55,10 +45,31 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
_fixture.CreatedUserId = message!.Data; _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<string>();
message?.Data.Should().NotBeEmpty();
_fixture.CreatedUserToken = message!.Data!;
_fixture.EnableAuthentication();
}
[Fact]
[Priority(10)]
public async Task Should_Get_Created_Users() 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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -74,16 +85,38 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
content.GivenName.Should().Be("Email"); 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<UserViewModel>();
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() public async Task Should_Update_User()
{ {
var user = new UpdateUserViewModel( var user = new UpdateUserViewModel(
_fixture.CreatedUserId, _fixture.CreatedUserId,
"newtest@email.com", "newtest@email.com",
"NewTest", "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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -96,10 +129,11 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
content.Should().BeEquivalentTo(user); content.Should().BeEquivalentTo(user);
} }
[Fact, Priority(20)] [Fact]
[Priority(20)]
public async Task Should_Get_Updated_Users() 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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -113,12 +147,49 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
content.Email.Should().Be("newtest@email.com"); content.Email.Should().Be("newtest@email.com");
content.Surname.Should().Be("NewTest"); content.Surname.Should().Be("NewTest");
content.GivenName.Should().Be("NewEmail"); content.GivenName.Should().Be("NewEmail");
_fixture.CreatedUserEmail = content.Email;
} }
[Fact, Priority(25)] [Fact]
public async Task Should_Get_One_User() [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<ChangePasswordViewModel>();
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<string>();
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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -128,17 +199,28 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
var content = message!.Data!.ToList(); var content = message!.Data!.ToList();
content.Should().ContainSingle(); content.Count.Should().Be(2);
content.First().Id.Should().Be(_fixture.CreatedUserId);
content.First().Email.Should().Be("newtest@email.com"); var currentUser = content.First(x => x.Id == _fixture.CreatedUserId);
content.First().Surname.Should().Be("NewTest");
content.First().GivenName.Should().Be("NewEmail"); 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() 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); response.StatusCode.Should().Be(HttpStatusCode.OK);
@ -149,20 +231,4 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
var content = message!.Data; var content = message!.Data;
content.Should().Be(_fixture.CreatedUserId); 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<IEnumerable<UserViewModel>>();
message?.Data.Should().NotBeNull();
var content = message!.Data!;
content.Should().BeNullOrEmpty();
}
} }

View File

@ -2,16 +2,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Data.Common; using System.Data.Common;
using System.Linq; using System.Linq;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArchitecture.IntegrationTests.Extensions; namespace CleanArchitecture.IntegrationTests.Extensions;
public static class FunctionalTestsServiceCollectionExtensions public static class FunctionalTestsServiceCollectionExtensions
{ {
public static IServiceCollection SetupTestDatabase<TContext>(this IServiceCollection services, DbConnection connection) where TContext : DbContext public static IServiceCollection SetupTestDatabase<TContext>(this IServiceCollection services,
DbConnection connection) where TContext : DbContext
{ {
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TContext>)); var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TContext>));
if (descriptor != null) if (descriptor != null)

View File

@ -10,7 +10,7 @@ public static class HttpExtensions
{ {
private static readonly JsonSerializerOptions JsonSerializerOptions = new() private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true
}; };
private static T? Deserialize<T>(string json) private static T? Deserialize<T>(string json)

View File

@ -9,8 +9,6 @@ namespace CleanArchitecture.IntegrationTests.Fixtures;
public class TestFixtureBase public class TestFixtureBase
{ {
public HttpClient ServerClient { get; }
public TestFixtureBase() public TestFixtureBase()
{ {
var projectDir = Directory.GetCurrentDirectory(); var projectDir = Directory.GetCurrentDirectory();
@ -25,6 +23,8 @@ public class TestFixtureBase
ServerClient.Timeout = TimeSpan.FromMinutes(5); ServerClient.Timeout = TimeSpan.FromMinutes(5);
} }
public HttpClient ServerClient { get; }
protected virtual void SeedTestData(ApplicationDbContext context) protected virtual void SeedTestData(ApplicationDbContext context)
{ {
} }

View File

@ -5,4 +5,12 @@ namespace CleanArchitecture.IntegrationTests.Fixtures;
public sealed class UserTestFixture : TestFixtureBase public sealed class UserTestFixture : TestFixtureBase
{ {
public Guid CreatedUserId { get; set; } 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}");
}
} }

View File

@ -18,11 +18,11 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
ServiceProvider serviceProvider, ServiceProvider serviceProvider,
IServiceProvider scopedServices); IServiceProvider scopedServices);
private readonly SqliteConnection _connection = new($"DataSource=:memory:");
private readonly AddCustomSeedDataHandler? _addCustomSeedDataHandler; private readonly AddCustomSeedDataHandler? _addCustomSeedDataHandler;
private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler;
private readonly SqliteConnection _connection = new("DataSource=:memory:");
private readonly string? _environment; private readonly string? _environment;
private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler;
public CleanArchitectureWebApplicationFactory( public CleanArchitectureWebApplicationFactory(
AddCustomSeedDataHandler? addCustomSeedDataHandler, AddCustomSeedDataHandler? addCustomSeedDataHandler,
@ -51,7 +51,7 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
using IServiceScope scope = sp.CreateScope(); using var scope = sp.CreateScope();
var scopedServices = scope.ServiceProvider; var scopedServices = scope.ServiceProvider;
var applicationDbContext = scopedServices.GetRequiredService<ApplicationDbContext>(); var applicationDbContext = scopedServices.GetRequiredService<ApplicationDbContext>();

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Interfaces.Repositories;
using MockQueryable.Moq; using MockQueryable.Moq;
using Moq; using Moq;
@ -10,19 +11,31 @@ namespace CleanArchitecture.gRPC.Tests.Fixtures;
public sealed class UserTestsFixture public sealed class UserTestsFixture
{ {
private Mock<IUserRepository> UserRepository { get; } = new ();
public UsersApiImplementation UsersApiImplementation { get; }
public IEnumerable<User> ExistingUsers { get; }
public UserTestsFixture() public UserTestsFixture()
{ {
ExistingUsers = new List<User>() ExistingUsers = new List<User>
{ {
new (Guid.NewGuid(), "test@test.de", "Test First Name", "Test Last Name"), new(
new (Guid.NewGuid(), "email@Email.de", "Email First Name", "Email Last Name"), Guid.NewGuid(),
new (Guid.NewGuid(), "user@user.de", "User First Name", "User Last Name"), "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(); var queryable = ExistingUsers.AsQueryable().BuildMock();
@ -33,4 +46,10 @@ public sealed class UserTestsFixture
UsersApiImplementation = new UsersApiImplementation(UserRepository.Object); UsersApiImplementation = new UsersApiImplementation(UserRepository.Object);
} }
private Mock<IUserRepository> UserRepository { get; } = new();
public UsersApiImplementation UsersApiImplementation { get; }
public IEnumerable<User> ExistingUsers { get; }
} }

View File

@ -1,26 +1,31 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 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 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 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 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 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 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 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 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 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 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 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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.ActiveCfg = Release|Any CPU
{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.Build.0 = Release|Any CPU {E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection 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 EndGlobal

16
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,4 +1,7 @@
# Clean Architecture Dotnet 7 API Project # 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. 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 ## Project Structure

View File

@ -1,2 +0,0 @@
- Remove warnings and apply suggestions
- Add authentication and authorization