From 2f6d73e6664e3b88a1e069714237eded2fdbcecf Mon Sep 17 00:00:00 2001 From: "Jefferson L. da Silva" Date: Mon, 19 Jan 2026 15:36:20 -0300 Subject: [PATCH 1/4] Enhances security and updates dependencies Improves user authentication with password hashing using Argon2id. Updates essential .NET packages and other dependencies to the latest versions. Refactors endpoint parameter order for consistency and readability. --- Directory.Packages.props | 57 +-- .../AuthenticationSetup/LoginRequest.cs | 4 +- .../Endpoints/GoogleOAuthEndpoints.cs | 4 +- .../Endpoints/InvoiceEndpoints.cs | 24 +- .../Endpoints/JobScheduleEndpoints.cs | 31 +- .../Endpoints/LoginEndpoints.cs | 17 +- .../Endpoints/ScanEmailDefinitionEndpoints.cs | 39 +- .../Endpoints/SendMessageEndpoints.cs | 3 +- .../Endpoints/UserEndpoints.cs | 37 +- .../Endpoints/UserPasswordEndpoints.cs | 95 +++++ .../InvoiceReminder.API.csproj | 1 - .../AppServices/BaseAppService.cs | 8 +- .../AppServices/UserAppService.cs | 39 ++ .../AppServices/UserPasswordAppService.cs | 98 +++++ .../Interfaces/IBaseAppService.cs | 2 +- .../Interfaces/IUserAppService.cs | 2 + .../Interfaces/IUserPasswordAppService.cs | 10 + .../InvoiceReminder.Application.csproj | 2 +- .../ViewModels/EmailAuthTokenViewModel.cs | 7 + .../ViewModels/InvoiceViewModel.cs | 7 + .../ViewModels/JobScheduleViewModel.cs | 4 + .../ScanEmailDefinitionViewModel.cs | 8 + .../ViewModels/UserPasswordViewModel.cs | 15 + .../ViewModels/UserViewModel.cs | 13 +- .../ViewModels/ViewModelDefaults.cs | 7 + .../Abstractions/UserClaims.cs | 7 + .../Extensions/StringHashExtension.cs | 45 +++ .../Interfaces/IJwtProvider.cs | 4 +- .../InvoiceReminder.Authentication.csproj | 10 +- .../Jwt/JwtProvider.cs | 4 +- .../Interfaces/IUserPasswordRepository.cs | 8 + ...20260109013522_Initial_Create.Designer.cs} | 96 +++-- ...te.cs => 20260109013522_Initial_Create.cs} | 72 +++- .../Migrations/CoreDbContextModelSnapshot.cs | 74 +++- .../Persistence/CoreDbContext.cs | 1 + .../Persistence/EntitiesConfig/UserConfig.cs | 7 +- .../EntitiesConfig/UserPasswordConfig.cs | 49 +++ .../Repository/UserPasswordRepository.cs | 62 +++ .../Repository/UserRepository.cs | 12 +- InvoiceReminder.Domain/Entities/User.cs | 2 +- .../Entities/UserPassword.cs | 8 + .../Extensions/UserExtensions.cs | 2 + .../Extensions/UserParameters.cs | 1 + .../BaseRepositoryIntegrationTests.cs | 2 +- .../Repository/UnitOfWorkIntegrationTests.cs | 3 +- .../UserPasswordRepositoryIntegrationTests.cs | 248 ++++++++++++ .../Data/Utils/TestData.cs | 1 - .../Endpoints/LoginEndpointTests.cs | 46 ++- .../Endpoints/UserEndpointsTests.cs | 1 - .../Endpoints/UserPasswordEndpointsTests.cs | 371 ++++++++++++++++++ .../Factories/CustomWebApplicationFactory.cs | 2 + .../AppServices/BaseAppServiceTests.cs | 2 +- .../AppServices/UserAppServiceTests.cs | 1 - .../UserPasswordAppServiceTests.cs | 355 +++++++++++++++++ .../ViewModels/UserPasswordViewModelTests.cs | 17 + .../Entities/UserPasswordTests.cs | 17 + .../Extensions/UserExtensionsTests.cs | 1 - .../SendMessage/SendMessageServiceTests.cs | 1 - .../Authentication/JwtProviderTests.cs | 4 +- .../StringHashExtensionTests.cs | 343 ++++++++++++++++ .../Data/EntitiesConfig/UserConfigTests.cs | 9 +- .../EntitiesConfig/UserPasswordConfigTests.cs | 79 ++++ global.json | 4 +- 63 files changed, 2256 insertions(+), 249 deletions(-) create mode 100644 InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs create mode 100644 InvoiceReminder.Application/AppServices/UserPasswordAppService.cs create mode 100644 InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs create mode 100644 InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs create mode 100644 InvoiceReminder.Authentication/Abstractions/UserClaims.cs create mode 100644 InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs rename InvoiceReminder.Data/Migrations/{20250930210104_Initial_Create.Designer.cs => 20260109013522_Initial_Create.Designer.cs} (79%) rename InvoiceReminder.Data/Migrations/{20250930210104_Initial_Create.cs => 20260109013522_Initial_Create.cs} (76%) create mode 100644 InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs create mode 100644 InvoiceReminder.Data/Repository/UserPasswordRepository.cs create mode 100644 InvoiceReminder.Domain/Entities/UserPassword.cs create mode 100644 InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs create mode 100644 InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs create mode 100644 InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs create mode 100644 InvoiceReminder.UnitTests.Application/ViewModels/UserPasswordViewModelTests.cs create mode 100644 InvoiceReminder.UnitTests.Domain/Entities/UserPasswordTests.cs create mode 100644 InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserPasswordConfigTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d56c1c..98a3848 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,25 +5,25 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -32,31 +32,32 @@ - - - + + + + - - + + - + - + - + - + diff --git a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs index cc4a3a6..0aafa05 100644 --- a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs +++ b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs @@ -2,6 +2,6 @@ namespace InvoiceReminder.API.AuthenticationSetup; public record LoginRequest { - public string Email { get; set; } - public string Password { get; set; } + public string Email { get; init; } + public string Password { get; init; } } diff --git a/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs b/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs index 54993c1..ec4cc91 100644 --- a/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs @@ -35,7 +35,7 @@ private static void MapGetAuthUrl(RouteGroupBuilder endpoint) private static void MapAuthorize(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/authorize", - async (IGoogleOAuthService oAuthService, CancellationToken ct, Guid state, string code) => + async (IGoogleOAuthService oAuthService, Guid state, string code, CancellationToken ct) => { var result = await oAuthService.GrantAuthorizationAsync(state, code, ct); @@ -52,7 +52,7 @@ private static void MapAuthorize(RouteGroupBuilder endpoint) private static void MapRevoke(RouteGroupBuilder endpoint) { - _ = endpoint.MapDelete("/revoke", async (IGoogleOAuthService oAuthService, CancellationToken ct, Guid id) => + _ = endpoint.MapDelete("/revoke", async (IGoogleOAuthService oAuthService, Guid id, CancellationToken ct) => { var result = await oAuthService.RevokeAuthorizationAsync(id, ct); diff --git a/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs b/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs index 5ef59a6..58c31d5 100644 --- a/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs @@ -22,9 +22,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapGetInvoices(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/", (IInvoiceAppService invoiceAppService) => + _ = endpoint.MapGet("/", (IInvoiceAppService appService) => { - var result = invoiceAppService.GetAll(); + var result = appService.GetAll(); return result.IsSuccess ? Results.Ok(result.Value) @@ -39,9 +39,9 @@ private static void MapGetInvoices(RouteGroupBuilder endpoint) private static void MapGetInvoice(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/{id}", async (IInvoiceAppService invoiceAppService, CancellationToken ct, Guid id) => + _ = endpoint.MapGet("/{id}", async (IInvoiceAppService appService, Guid id, CancellationToken ct) => { - var result = await invoiceAppService.GetByIdAsync(id, ct); + var result = await appService.GetByIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -58,9 +58,9 @@ private static void MapGetInvoice(RouteGroupBuilder endpoint) private static void MapGetInvoiceByBarcode(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/getby-barcode/{value}", - async (IInvoiceAppService invoiceAppService, CancellationToken ct, string value) => + async (IInvoiceAppService appService, string value, CancellationToken ct) => { - var result = await invoiceAppService.GetByBarcodeAsync(value, ct); + var result = await appService.GetByBarcodeAsync(value, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -77,9 +77,9 @@ private static void MapGetInvoiceByBarcode(RouteGroupBuilder endpoint) private static void MapCreateInvoice(RouteGroupBuilder endpoint) { _ = endpoint.MapPost("/", - async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) => + async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) => { - var result = await invoiceAppService.AddAsync(invoiceViewModel, ct); + var result = await appService.AddAsync(viewModel, ct); return result.IsSuccess ? Results.Created($"{basepath}/{result.Value.Barcode}", result.Value) @@ -95,9 +95,9 @@ private static void MapCreateInvoice(RouteGroupBuilder endpoint) private static void MapUpdateInvoice(RouteGroupBuilder endpoint) { _ = endpoint.MapPut("/", - async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) => + async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) => { - var result = await invoiceAppService.UpdateAsync(invoiceViewModel, ct); + var result = await appService.UpdateAsync(viewModel, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -113,9 +113,9 @@ private static void MapUpdateInvoice(RouteGroupBuilder endpoint) private static void MapDeleteInvoice(RouteGroupBuilder endpoint) { _ = endpoint.MapDelete("/", - async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) => + async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) => { - var result = await invoiceAppService.RemoveAsync(invoiceViewModel, ct); + var result = await appService.RemoveAsync(viewModel, ct); return result.IsSuccess ? Results.NoContent() diff --git a/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs b/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs index 2db5d02..ed9e853 100644 --- a/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs @@ -22,9 +22,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapGetJobSchedules(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/", (IJobScheduleAppService jobScheduleAppService) => + _ = endpoint.MapGet("/", (IJobScheduleAppService appService) => { - var result = jobScheduleAppService.GetAll(); + var result = appService.GetAll(); return result.IsSuccess ? Results.Ok(result.Value) @@ -39,9 +39,10 @@ private static void MapGetJobSchedules(RouteGroupBuilder endpoint) private static void MapGetJobSchedule(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/{id}", async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, Guid id) => + _ = endpoint.MapGet("/{id}", + async (IJobScheduleAppService appService, Guid id, CancellationToken ct) => { - var result = await jobScheduleAppService.GetByIdAsync(id, ct); + var result = await appService.GetByIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -58,9 +59,9 @@ private static void MapGetJobSchedule(RouteGroupBuilder endpoint) private static void MapGetJobScheduleByUserId(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/getby-userid/{id}", - async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, Guid id) => + async (IJobScheduleAppService appService, Guid id, CancellationToken ct) => { - var result = await jobScheduleAppService.GetByUserIdAsync(id, ct); + var result = await appService.GetByUserIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -77,10 +78,10 @@ private static void MapGetJobScheduleByUserId(RouteGroupBuilder endpoint) private static void MapCreateJobSchedule(RouteGroupBuilder endpoint) { _ = endpoint.MapPost("/", - async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, - [FromBody] JobScheduleViewModel jobScheduleViewModel) => + async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel, + CancellationToken ct) => { - var result = await jobScheduleAppService.AddNewJobAsync(jobScheduleViewModel, ct); + var result = await appService.AddNewJobAsync(viewModel, ct); return result.IsSuccess ? Results.Created($"{basepath}/{result.Value.Id}", result.Value) @@ -96,10 +97,10 @@ private static void MapCreateJobSchedule(RouteGroupBuilder endpoint) private static void MapUpdateJobSchedule(RouteGroupBuilder endpoint) { _ = endpoint.MapPut("/", - async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, - [FromBody] JobScheduleViewModel jobScheduleViewModel) => + async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel, + CancellationToken ct) => { - var result = await jobScheduleAppService.UpdateAsync(jobScheduleViewModel, ct); + var result = await appService.UpdateAsync(viewModel, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -115,10 +116,10 @@ private static void MapUpdateJobSchedule(RouteGroupBuilder endpoint) private static void MapDeleteJobSchedule(RouteGroupBuilder endpoint) { _ = endpoint.MapDelete("/", - async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, - [FromBody] JobScheduleViewModel jobScheduleViewModel) => + async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel, + CancellationToken ct) => { - var result = await jobScheduleAppService.RemoveAsync(jobScheduleViewModel, ct); + var result = await appService.RemoveAsync(viewModel, ct); return result.IsSuccess ? Results.NoContent() diff --git a/InvoiceReminder.API/Endpoints/LoginEndpoints.cs b/InvoiceReminder.API/Endpoints/LoginEndpoints.cs index fe65f0b..9f085ea 100644 --- a/InvoiceReminder.API/Endpoints/LoginEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/LoginEndpoints.cs @@ -1,6 +1,6 @@ using InvoiceReminder.API.AuthenticationSetup; using InvoiceReminder.Application.Interfaces; -using InvoiceReminder.Authentication.Extensions; +using InvoiceReminder.Authentication.Abstractions; using InvoiceReminder.Authentication.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -20,23 +20,18 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapLogin(IEndpointRouteBuilder endpoints) { _ = endpoints.MapPost("/", - async (IJwtProvider jwtProvider, - IUserAppService userAppService, - CancellationToken ct, - [FromBody] LoginRequest request) => + async (IUserAppService appService, IJwtProvider jwtProvider, [FromBody] LoginRequest request, + CancellationToken ct) => { if (string.IsNullOrWhiteSpace(request?.Email) || string.IsNullOrWhiteSpace(request?.Password)) { return Results.BadRequest("Email e senha são obrigatórios"); } - var result = await userAppService.GetByEmailAsync(request.Email, ct); + var result = await appService.ValidateUserPasswordAsync(request.Email, request.Password, ct); - var isValid = result.IsSuccess - && request.Password.ToSHA256().Equals(result.Value.Password); - - return isValid - ? Results.Ok(jwtProvider.Generate(result.Value)) + return result.IsSuccess + ? Results.Ok(jwtProvider.Generate(new UserClaims { Id = result.Value.Id, Email = result.Value.Email })) : Results.Unauthorized(); }) .WithName("Login") diff --git a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs index b84c469..7b917ac 100644 --- a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs @@ -23,9 +23,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapGetScanEmailDefinitions(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/", (IScanEmailDefinitionAppService scanEmailDefinitionAppService) => + _ = endpoint.MapGet("/", (IScanEmailDefinitionAppService appService) => { - var result = scanEmailDefinitionAppService.GetAll(); + var result = appService.GetAll(); return result.IsSuccess ? Results.Ok(result.Value) @@ -41,9 +41,9 @@ private static void MapGetScanEmailDefinitions(RouteGroupBuilder endpoint) private static void MapGetScanEmailDefinition(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/{id}", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct, Guid id) => + async (IScanEmailDefinitionAppService appService, Guid id, CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.GetByIdAsync(id, ct); + var result = await appService.GetByIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -60,9 +60,9 @@ private static void MapGetScanEmailDefinition(RouteGroupBuilder endpoint) private static void MapGetByUserId(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/getby-userid/{id}", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct, Guid id) => + async (IScanEmailDefinitionAppService appService, Guid id, CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.GetByUserIdAsync(id, ct); + var result = await appService.GetByUserIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -79,12 +79,9 @@ private static void MapGetByUserId(RouteGroupBuilder endpoint) private static void MapGetBySenderEmailAddress(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/{email}/{id}", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, - CancellationToken ct, - string email, - Guid id) => + async (IScanEmailDefinitionAppService appService, string email, Guid id, CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.GetBySenderEmailAddressAsync(email, id, ct); + var result = await appService.GetBySenderEmailAddressAsync(email, id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -101,11 +98,10 @@ private static void MapGetBySenderEmailAddress(RouteGroupBuilder endpoint) private static void MapCreateScanEmailDefinition(RouteGroupBuilder endpoint) { _ = endpoint.MapPost("/", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, - CancellationToken ct, - [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) => + async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel, + CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.AddAsync(scanEmailDefinitionViewModel, ct); + var result = await appService.AddAsync(viewModel, ct); return result.IsSuccess ? Results.Created($"{basepath}/{result.Value.Id}", result.Value) @@ -121,11 +117,10 @@ private static void MapCreateScanEmailDefinition(RouteGroupBuilder endpoint) private static void MapUpdateScanEmailDefinition(RouteGroupBuilder endpoint) { _ = endpoint.MapPut("/", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, - CancellationToken ct, - [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) => + async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel, + CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.UpdateAsync(scanEmailDefinitionViewModel, ct); + var result = await appService.UpdateAsync(viewModel, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -141,10 +136,10 @@ private static void MapUpdateScanEmailDefinition(RouteGroupBuilder endpoint) private static void MapDeleteScanEmailDefinition(RouteGroupBuilder endpoint) { _ = endpoint.MapDelete("/", - async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct, - [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) => + async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel, + CancellationToken ct) => { - var result = await scanEmailDefinitionAppService.RemoveAsync(scanEmailDefinitionViewModel, ct); + var result = await appService.RemoveAsync(viewModel, ct); return result.IsSuccess ? Results.NoContent() diff --git a/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs b/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs index 61e8004..a52811d 100644 --- a/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs @@ -15,8 +15,7 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapSendMessage(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/{id}", - async (ISendMessageService messageService, CancellationToken ct, Guid id) => + _ = endpoint.MapGet("/{id}", async (ISendMessageService messageService, Guid id, CancellationToken ct) => { var result = await messageService.SendMessage(id, ct); diff --git a/InvoiceReminder.API/Endpoints/UserEndpoints.cs b/InvoiceReminder.API/Endpoints/UserEndpoints.cs index eacb5b4..fd6b971 100644 --- a/InvoiceReminder.API/Endpoints/UserEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/UserEndpoints.cs @@ -1,6 +1,5 @@ using InvoiceReminder.Application.Interfaces; using InvoiceReminder.Application.ViewModels; -using InvoiceReminder.Authentication.Extensions; using Microsoft.AspNetCore.Mvc; namespace InvoiceReminder.API.Endpoints; @@ -24,9 +23,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints) private static void MapGetUsers(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/", (IUserAppService userAppService) => + _ = endpoint.MapGet("/", (IUserAppService appService) => { - var result = userAppService.GetAll(); + var result = appService.GetAll(); return result.IsSuccess ? Results.Ok(result.Value) @@ -41,9 +40,9 @@ private static void MapGetUsers(RouteGroupBuilder endpoint) private static void MapGetUser(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/{id}", async (IUserAppService userAppService, CancellationToken ct, Guid id) => + _ = endpoint.MapGet("/{id}", async (IUserAppService appService, Guid id, CancellationToken ct) => { - var result = await userAppService.GetByIdAsync(id, ct); + var result = await appService.GetByIdAsync(id, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -59,9 +58,9 @@ private static void MapGetUser(RouteGroupBuilder endpoint) private static void MapGetUserByEmail(RouteGroupBuilder endpoint) { _ = endpoint.MapGet("/getby-email/{value}", - async (IUserAppService userAppService, CancellationToken ct, string value) => + async (IUserAppService appService, string value, CancellationToken ct) => { - var result = await userAppService.GetByEmailAsync(value, ct); + var result = await appService.GetByEmailAsync(value, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -77,11 +76,9 @@ private static void MapGetUserByEmail(RouteGroupBuilder endpoint) private static void MapCreateUser(RouteGroupBuilder endpoint) { _ = endpoint.MapPost("/", - async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) => + async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) => { - userViewModel.Password = userViewModel.Password.ToSHA256(); - - var result = await userAppService.AddAsync(userViewModel, ct); + var result = await appService.AddAsync(viewModel, ct); return result.IsSuccess ? Results.Created($"{basepath}/{result.Value.Id}", result.Value) @@ -97,14 +94,10 @@ private static void MapCreateUser(RouteGroupBuilder endpoint) private static void MapCreateUsers(RouteGroupBuilder endpoint) { _ = endpoint.MapPost("/bulk-insert", - async (IUserAppService userAppService, CancellationToken ct, [FromBody] ICollection usersViewModel) => + async (IUserAppService appService, [FromBody] ICollection viewModelCollection, + CancellationToken ct) => { - foreach (var user in usersViewModel) - { - user.Password = user.Password.ToSHA256(); - } - - var result = await userAppService.BulkInsertAsync(usersViewModel, ct); + var result = await appService.BulkInsertAsync(viewModelCollection, ct); return result.IsSuccess ? Results.Created($"{basepath}", result.Value) @@ -120,9 +113,9 @@ private static void MapCreateUsers(RouteGroupBuilder endpoint) private static void MapUpdateUser(RouteGroupBuilder endpoint) { _ = endpoint.MapPut("/", - async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) => + async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) => { - var result = await userAppService.UpdateAsync(userViewModel, ct); + var result = await appService.UpdateAsync(viewModel, ct); return result.IsSuccess ? Results.Ok(result.Value) @@ -138,9 +131,9 @@ private static void MapUpdateUser(RouteGroupBuilder endpoint) private static void MapDeleteUser(RouteGroupBuilder endpoint) { _ = endpoint.MapDelete("/", - async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) => + async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) => { - var result = await userAppService.RemoveAsync(userViewModel, ct); + var result = await appService.RemoveAsync(viewModel, ct); return result.IsSuccess ? Results.NoContent() diff --git a/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs b/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs new file mode 100644 index 0000000..997330e --- /dev/null +++ b/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs @@ -0,0 +1,95 @@ +using InvoiceReminder.Application.Interfaces; +using InvoiceReminder.Application.ViewModels; +using Microsoft.AspNetCore.Mvc; + +namespace InvoiceReminder.API.Endpoints; + +public class UserPasswordEndpoints : IEndpointDefinition +{ + private const string basepath = "/api/user_password"; + + public void RegisterEndpoints(IEndpointRouteBuilder endpoints) + { + var endpoint = endpoints.MapGroup(basepath).WithName("UserPasswordEndpoints"); + + MapCreateUserPassword(endpoint); + MapCreateUsersPassword(endpoint); + MapDeleteUserPassword(endpoint); + MapUpdateUserPassword(endpoint); + } + + private static void MapCreateUserPassword(RouteGroupBuilder endpoint) + { + _ = endpoint.MapPost("/", + async (IUserPasswordAppService appService, UserPasswordViewModel viewModel, CancellationToken ct) => + { + var result = await appService.AddAsync(viewModel, ct); + + return result.IsSuccess + ? Results.Created($"/api/user_password/{result.Value.Id}", result.Value) + : Results.Problem(result.Error); + }) + .WithName("CreateUserPassword") + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } + + private static void MapCreateUsersPassword(RouteGroupBuilder endpoint) + { + _ = endpoint.MapPost("/bulk-insert", + async (IUserPasswordAppService appService, ICollection viewModelCollection, + CancellationToken ct) => + { + var result = await appService.BulkInsertAsync(viewModelCollection, ct); + + return result.IsSuccess + ? Results.Created($"/api/user_password/bulk-insert", result.Value) + : Results.Problem(result.Error); + }) + .WithName("BulkCreateUserPassword") + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } + + private static void MapUpdateUserPassword(RouteGroupBuilder endpoint) + { + _ = endpoint.MapPut("/", + async (IUserPasswordAppService appService, [FromBody] UserPasswordViewModel viewModel, + CancellationToken ct) => + { + var result = await appService.UpdateAsync(viewModel, ct); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.Problem(result.Error); + }) + .WithName("UpdateUserPassword") + .RequireAuthorization() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } + + private static void MapDeleteUserPassword(RouteGroupBuilder endpoint) + { + _ = endpoint.MapDelete("/", + async (IUserPasswordAppService appService, [FromBody] UserPasswordViewModel viewModel, + CancellationToken ct) => + { + var result = await appService.RemoveAsync(viewModel, ct); + + return result.IsSuccess + ? Results.NoContent() + : Results.Problem(result.Error); + }) + .WithName("DeleteUserPassword") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } +} diff --git a/InvoiceReminder.API/InvoiceReminder.API.csproj b/InvoiceReminder.API/InvoiceReminder.API.csproj index 14517a2..2ac0a6d 100644 --- a/InvoiceReminder.API/InvoiceReminder.API.csproj +++ b/InvoiceReminder.API/InvoiceReminder.API.csproj @@ -6,7 +6,6 @@ - diff --git a/InvoiceReminder.Application/AppServices/BaseAppService.cs b/InvoiceReminder.Application/AppServices/BaseAppService.cs index 741db23..fb5d4f0 100644 --- a/InvoiceReminder.Application/AppServices/BaseAppService.cs +++ b/InvoiceReminder.Application/AppServices/BaseAppService.cs @@ -35,15 +35,15 @@ public virtual async Task> AddAsync( } public virtual async Task> BulkInsertAsync( - ICollection viewModels, + ICollection viewModelCollection, CancellationToken cancellationToken = default) { - if (viewModels is null || viewModels.Count == 0) + if (viewModelCollection is null || viewModelCollection.Count == 0) { - return Result.Failure($"Parameter {nameof(viewModels)} was Null or Empty."); + return Result.Failure($"Parameter {nameof(viewModelCollection)} was Null or Empty."); } - var result = await _repository.BulkInsertAsync(viewModels.Adapt>(), cancellationToken); + var result = await _repository.BulkInsertAsync(viewModelCollection.Adapt>(), cancellationToken); return Result.Success(result); } diff --git a/InvoiceReminder.Application/AppServices/UserAppService.cs b/InvoiceReminder.Application/AppServices/UserAppService.cs index f21a055..030288f 100644 --- a/InvoiceReminder.Application/AppServices/UserAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserAppService.cs @@ -1,5 +1,6 @@ using InvoiceReminder.Application.Interfaces; using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Authentication.Extensions; using InvoiceReminder.Data.Interfaces; using InvoiceReminder.Domain.Abstractions; using InvoiceReminder.Domain.Entities; @@ -10,10 +11,36 @@ namespace InvoiceReminder.Application.AppServices; public sealed class UserAppService : BaseAppService, IUserAppService { private readonly IUserRepository _repository; + private readonly IUnitOfWork _unitOfWork; public UserAppService(IUserRepository repository, IUnitOfWork unitOfWork) : base(repository, unitOfWork) { _repository = repository; + _unitOfWork = unitOfWork; + } + + public override async Task> AddAsync( + UserViewModel viewModel, + CancellationToken cancellationToken = default) + { + if (viewModel is null) + { + return Result.Failure($"Parameter {nameof(viewModel)} was Null."); + } + + var tempPassword = DateTime.Now.ToString("ddMMyyyy"); + (var pHash, var pSalt) = tempPassword.HashPassword(); + + viewModel.UserPassword.UserId = viewModel.Id; + viewModel.UserPassword.PasswordHash = pHash; + viewModel.UserPassword.PasswordSalt = pSalt; + + var entity = viewModel.Adapt(); + + _ = await _repository.AddAsync(entity, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(entity.Adapt()); } public async Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default) @@ -24,4 +51,16 @@ public async Task> GetByEmailAsync(string value, Cancellat ? Result.Failure("User not Found.") : Result.Success(entity.Adapt()); } + + public async Task> ValidateUserPasswordAsync(string email, string password, + CancellationToken cancellationToken = default) + { + var entity = await _repository.GetByEmailAsync(email, cancellationToken); + var isValid = entity is not null && + password.VerifyPassword(entity.UserPassword.PasswordHash, entity.UserPassword.PasswordSalt); + + return !isValid + ? Result.Failure("User not Found.") + : Result.Success(entity.Adapt()); + } } diff --git a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs new file mode 100644 index 0000000..e68c55b --- /dev/null +++ b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs @@ -0,0 +1,98 @@ +using InvoiceReminder.Application.Interfaces; +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Authentication.Extensions; +using InvoiceReminder.Data.Interfaces; +using InvoiceReminder.Domain.Abstractions; +using InvoiceReminder.Domain.Entities; +using Mapster; + +namespace InvoiceReminder.Application.AppServices; + +public class UserPasswordAppService : BaseAppService, IUserPasswordAppService +{ + private readonly IUserPasswordRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public UserPasswordAppService(IUserPasswordRepository repository, IUnitOfWork unitOfWork) + : base(repository, unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public override async Task> AddAsync( + UserPasswordViewModel viewModel, + CancellationToken cancellationToken = default) + { + if (viewModel is null) + { + return Result.Failure("The provided obejct data was Null."); + } + + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + + viewModel.PasswordHash = pHash; + viewModel.PasswordSalt = pSalt; + + var entity = viewModel.Adapt(); + + _ = await _repository.AddAsync(entity, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(entity.Adapt()); + } + + public override async Task> BulkInsertAsync( + ICollection viewModelCollection, + CancellationToken cancellationToken = default) + { + if (viewModelCollection is null or { Count: 0 }) + { + return Result.Failure("The provided obejct data was Null or Empty."); + } + + foreach (var viewModel in viewModelCollection) + { + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + + viewModel.PasswordHash = pHash; + viewModel.PasswordSalt = pSalt; + } + + var result = await _repository + .BulkInsertAsync(viewModelCollection.Adapt>(), cancellationToken); + + return Result.Success(result); + } + + public async Task> GetByUserIdAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + var entity = await _repository.GetByUserIdAsync(userId, cancellationToken); + + return entity is null + ? Result.Failure("No user password found for the specified user ID.") + : Result.Success(entity.Adapt()); + } + + public override async Task> UpdateAsync( + UserPasswordViewModel viewModel, + CancellationToken cancellationToken = default) + { + if (viewModel is null) + { + return Result.Failure("The provided obejct data was Null."); + } + + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + + viewModel.PasswordHash = pHash; + viewModel.PasswordSalt = pSalt; + + _ = _repository.Update(viewModel.Adapt()); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(viewModel); + } +} diff --git a/InvoiceReminder.Application/Interfaces/IBaseAppService.cs b/InvoiceReminder.Application/Interfaces/IBaseAppService.cs index 5930b2d..b071b27 100644 --- a/InvoiceReminder.Application/Interfaces/IBaseAppService.cs +++ b/InvoiceReminder.Application/Interfaces/IBaseAppService.cs @@ -5,7 +5,7 @@ namespace InvoiceReminder.Application.Interfaces; public interface IBaseAppService where TEntity : class where TEntityViewModel : class { Task> AddAsync(TEntityViewModel viewModel, CancellationToken cancellationToken = default); - Task> BulkInsertAsync(ICollection viewModels, CancellationToken cancellationToken = default); + Task> BulkInsertAsync(ICollection viewModelCollection, CancellationToken cancellationToken = default); Result> GetAll(); Task> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task> RemoveAsync(TEntityViewModel viewModel, CancellationToken cancellationToken = default); diff --git a/InvoiceReminder.Application/Interfaces/IUserAppService.cs b/InvoiceReminder.Application/Interfaces/IUserAppService.cs index 07161ae..7c2fc1a 100644 --- a/InvoiceReminder.Application/Interfaces/IUserAppService.cs +++ b/InvoiceReminder.Application/Interfaces/IUserAppService.cs @@ -7,4 +7,6 @@ namespace InvoiceReminder.Application.Interfaces; public interface IUserAppService : IBaseAppService { Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default); + Task> ValidateUserPasswordAsync(string email, string password, + CancellationToken cancellationToken = default); } diff --git a/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs b/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs new file mode 100644 index 0000000..1823030 --- /dev/null +++ b/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs @@ -0,0 +1,10 @@ +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Domain.Abstractions; +using InvoiceReminder.Domain.Entities; + +namespace InvoiceReminder.Application.Interfaces; + +public interface IUserPasswordAppService : IBaseAppService +{ + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/InvoiceReminder.Application/InvoiceReminder.Application.csproj b/InvoiceReminder.Application/InvoiceReminder.Application.csproj index 90a6123..b2acb90 100644 --- a/InvoiceReminder.Application/InvoiceReminder.Application.csproj +++ b/InvoiceReminder.Application/InvoiceReminder.Application.csproj @@ -9,6 +9,7 @@ + @@ -24,7 +25,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs b/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs index 9725044..7bb5942 100644 --- a/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs @@ -1,20 +1,27 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; + public class EmailAuthTokenViewModel : ViewModelDefaults { [Required] + [JsonPropertyOrder(2)] public Guid UserId { get; set; } [Required] + [JsonPropertyOrder(3)] public string AccessToken { get; set; } [Required] + [JsonPropertyOrder(4)] public string RefreshToken { get; set; } [Required] + [JsonPropertyOrder(5)] public DateTime AccessTokenExpiry { get; set; } + [JsonPropertyOrder(6)] public bool IsStale => AccessTokenExpiry < DateTime.UtcNow; public EmailAuthTokenViewModel() diff --git a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs index bddb626..bba7fdf 100644 --- a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs @@ -1,24 +1,31 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; public class InvoiceViewModel : ViewModelDefaults { [Required] + [JsonPropertyOrder(2)] public Guid UserId { get; set; } [Required] + [JsonPropertyOrder(3)] public string Bank { get; set; } + [JsonPropertyOrder(3)] public string Beneficiary { get; set; } [Required] + [JsonPropertyOrder(4)] public decimal Amount { get; set; } [Required] + [JsonPropertyOrder(5)] public string Barcode { get; set; } [Required] + [JsonPropertyOrder(6)] public DateTime DueDate { get; set; } public InvoiceViewModel() diff --git a/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs b/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs index c07390e..bc41af8 100644 --- a/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs @@ -1,12 +1,16 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; + public class JobScheduleViewModel : ViewModelDefaults { [Required] + [JsonPropertyOrder(2)] public Guid UserId { get; set; } [Required] + [JsonPropertyOrder(3)] public string CronExpression { get; set; } public JobScheduleViewModel() diff --git a/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs b/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs index 4abfd62..a0e2ca6 100644 --- a/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs @@ -1,25 +1,33 @@ using InvoiceReminder.Domain.Enums; using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; + public class ScanEmailDefinitionViewModel : ViewModelDefaults { [Required] + [JsonPropertyOrder(2)] public Guid UserId { get; set; } [Required] + [JsonPropertyOrder(3)] public InvoiceType InvoiceType { get; set; } [Required] + [JsonPropertyOrder(4)] public string Beneficiary { get; set; } [Required] + [JsonPropertyOrder(5)] public string Description { get; set; } [Required] + [JsonPropertyOrder(6)] public string SenderEmailAddress { get; set; } [Required] + [JsonPropertyOrder(7)] public string AttachmentFileName { get; set; } public ScanEmailDefinitionViewModel() diff --git a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs new file mode 100644 index 0000000..16df85b --- /dev/null +++ b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace InvoiceReminder.Application.ViewModels; + +public class UserPasswordViewModel : ViewModelDefaults +{ + [JsonPropertyOrder(2)] + public Guid UserId { get; set; } + + [JsonIgnore] + public string PasswordHash { get; set; } + + [JsonIgnore] + public string PasswordSalt { get; set; } +} diff --git a/InvoiceReminder.Application/ViewModels/UserViewModel.cs b/InvoiceReminder.Application/ViewModels/UserViewModel.cs index d861f3b..742b697 100644 --- a/InvoiceReminder.Application/ViewModels/UserViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/UserViewModel.cs @@ -1,26 +1,32 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; public class UserViewModel : ViewModelDefaults { + [JsonPropertyOrder(2)] public long TelegramChatId { get; set; } [Required] + [JsonPropertyOrder(3)] public string Name { get; set; } [Required] [EmailAddress] + [JsonPropertyOrder(4)] public string Email { get; set; } - [Required] - [StringLength(100, MinimumLength = 6)] - public string Password { get; set; } + [JsonIgnore] + public virtual UserPasswordViewModel UserPassword { get; set; } + [JsonPropertyOrder(5)] public virtual ICollection Invoices { get; set; } + [JsonPropertyOrder(6)] public virtual ICollection JobSchedules { get; set; } + [JsonPropertyOrder(7)] public virtual ICollection ScanEmailDefinitions { get; set; } public UserViewModel() @@ -29,5 +35,6 @@ public UserViewModel() Invoices = []; JobSchedules = []; ScanEmailDefinitions = []; + UserPassword = new(); } } diff --git a/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs b/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs index 7997831..4cc4c95 100644 --- a/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs +++ b/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs @@ -1,8 +1,15 @@ +using System.Text.Json.Serialization; + namespace InvoiceReminder.Application.ViewModels; public class ViewModelDefaults { + [JsonPropertyOrder(1)] public Guid Id { get; set; } + + [JsonPropertyOrder(11)] public DateTime CreatedAt { get; set; } + + [JsonPropertyOrder(12)] public DateTime UpdatedAt { get; set; } } diff --git a/InvoiceReminder.Authentication/Abstractions/UserClaims.cs b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs new file mode 100644 index 0000000..2ede79b --- /dev/null +++ b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs @@ -0,0 +1,7 @@ +namespace InvoiceReminder.Authentication.Abstractions; + +public record UserClaims +{ + public Guid Id { get; init; } + public string Email { get; init; } +} diff --git a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs index e57c29f..8535bde 100644 --- a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs +++ b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs @@ -1,3 +1,4 @@ +using Konscious.Security.Cryptography; using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -6,6 +7,50 @@ namespace InvoiceReminder.Authentication.Extensions; public static class StringHashExtension { + public static (string Hash, string Salt) HashPassword(this string inputString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputString); + + var salt = RandomNumberGenerator.GetBytes(16); + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + { + Salt = salt, + DegreeOfParallelism = 8, + Iterations = 4, + MemorySize = 1024 * 64 + }; + + var hashBytes = argon2.GetBytes(32); + var hash = Convert.ToBase64String(hashBytes); + var saltBase64 = Convert.ToBase64String(salt); + + return (hash, saltBase64); + } + + public static bool VerifyPassword(this string inputString, string storedHash, string storedSalt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputString); + + var salt = Convert.FromBase64String(storedSalt); + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + { + Salt = salt, + DegreeOfParallelism = 8, + Iterations = 4, + MemorySize = 1024 * 64 + }; + + var hashBytes = argon2.GetBytes(32); + var hash = Convert.ToBase64String(hashBytes); + + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(storedHash), + Convert.FromBase64String(hash) + ); + } + public static string ToSHA256(this string inputString) { var bytes = Encoding.UTF8.GetBytes(inputString); diff --git a/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs b/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs index f7e7c63..92888a1 100644 --- a/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs +++ b/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs @@ -1,9 +1,9 @@ -using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Authentication.Abstractions; using InvoiceReminder.Authentication.Jwt; namespace InvoiceReminder.Authentication.Interfaces; public interface IJwtProvider { - JwtObject Generate(UserViewModel user); + JwtObject Generate(UserClaims user); } diff --git a/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj b/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj index 6e98d95..141bb16 100644 --- a/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj +++ b/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj @@ -1,10 +1,7 @@ - - - - - - + + + @@ -12,6 +9,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs index e7cb5b8..62c20a6 100644 --- a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs +++ b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs @@ -1,4 +1,4 @@ -using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Authentication.Abstractions; using InvoiceReminder.Authentication.Interfaces; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -20,7 +20,7 @@ public JwtProvider(IOptions jwtOptions) _jwtOptions = jwtOptions.Value; } - public JwtObject Generate(UserViewModel user) + public JwtObject Generate(UserClaims user) { ArgumentNullException.ThrowIfNull(user); diff --git a/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs b/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs new file mode 100644 index 0000000..e990407 --- /dev/null +++ b/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs @@ -0,0 +1,8 @@ +using InvoiceReminder.Domain.Entities; + +namespace InvoiceReminder.Data.Interfaces; + +public interface IUserPasswordRepository : IBaseRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs similarity index 79% rename from InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs rename to InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs index 7a38156..b278148 100644 --- a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs +++ b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs @@ -12,7 +12,7 @@ namespace InvoiceReminder.Data.Migrations { [DbContext(typeof(CoreDbContext))] - [Migration("20250930210104_Initial_Create")] + [Migration("20260109013522_Initial_Create")] partial class Initial_Create { /// @@ -21,7 +21,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("invoice_reminder") - .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -40,12 +40,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("access_token"); b.Property("AccessTokenExpiry") - .HasColumnType("timestamp") + .HasColumnType("timestamp with time zone") .HasColumnName("access_token_expiry"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp") + .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); b.Property("NonceValue") @@ -67,8 +66,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("token_provider"); b.Property("UpdatedAt") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("timestamp") + .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); b.Property("UserId") @@ -111,17 +109,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("beneficiary"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); b.Property("DueDate") - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("due_date"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); b.Property("UserId") @@ -143,8 +139,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); b.Property("CronExpression") @@ -154,8 +149,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("cron_expression"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); b.Property("UserId") @@ -189,8 +183,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("beneficiary"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); b.Property("Description") @@ -210,8 +203,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("sender_email_address"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); b.Property("UserId") @@ -233,8 +225,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); b.Property("Email") @@ -249,12 +240,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(255)") .HasColumnName("name"); - b.Property("Password") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("password"); - b.Property("TelegramChatId") .ValueGeneratedOnAdd() .HasColumnType("bigint") @@ -262,19 +247,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("telegram_chat_id"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() - .HasColumnType("date") + .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); b.HasKey("Id"); b.HasIndex("Email") .IsUnique() - .HasDatabaseName("idx_user_email"); + .HasDatabaseName("IX_user_email"); b.ToTable("user", "invoice_reminder"); }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("password_hash"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("password_salt"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_password", "invoice_reminder"); + }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.EmailAuthToken", b => { b.HasOne("InvoiceReminder.Domain.Entities.User", null) @@ -311,6 +334,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b => + { + b.HasOne("InvoiceReminder.Domain.Entities.User", null) + .WithOne("UserPassword") + .HasForeignKey("InvoiceReminder.Domain.Entities.UserPassword", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.User", b => { b.Navigation("EmailAuthTokens"); @@ -320,6 +352,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("JobSchedules"); b.Navigation("ScanEmailDefinitions"); + + b.Navigation("UserPassword"); }); #pragma warning restore 612, 618 } diff --git a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs similarity index 76% rename from InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs rename to InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs index 556f159..2d5bc94 100644 --- a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs +++ b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs @@ -25,24 +25,22 @@ protected override void Up(MigrationBuilder migrationBuilder) telegram_chat_id = table.Column(type: "bigint", nullable: false, defaultValue: 0L), name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), - password = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false), updated_at = table.Column(type: "timestamp with time zone", nullable: false) }, - constraints: table => _ = table.PrimaryKey("PK_user", x => x.id)); + constraints: table => table.PrimaryKey("PK_user", x => x.id)); _ = migrationBuilder.InsertData( table: "user", schema: "invoice_reminder", - columns: ["id", "telegram_chat_id", "name", "email", "password", "created_at", "updated_at"], + columns: ["id", "telegram_chat_id", "name", "email", "created_at", "updated_at"], values: new object[,] { { "0d77a03d-ac35-480c-b409-a08133409c7c", 0, "John Doe", - "john.doe@notmail.com", - "8D969EEF6ECAD3C29A3A629280E686CF0C3F5D5A86AFF3CA12020C923ADC6C92", + "john.doe@fakemail.com", new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc), new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc) }, @@ -50,8 +48,55 @@ protected override void Up(MigrationBuilder migrationBuilder) "59918776-f6c6-4def-93b1-95d7a7717942", 0, "Jane Doe", - "jane.doe@notmail.com", - "8D969EEF6ECAD3C29A3A629280E686CF0C3F5D5A86AFF3CA12020C923ADC6C92", + "jane.doe@fakemail.com", + new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc) + } + }); + + _ = migrationBuilder.CreateTable( + name: "user_password", + schema: "invoice_reminder", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + password_hash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + password_salt = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + _ = table.PrimaryKey("PK_user_password", x => x.id); + _ = table.ForeignKey( + name: "FK_user_password_user_user_id", + column: x => x.user_id, + principalSchema: "invoice_reminder", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + _ = migrationBuilder.InsertData( + table: "user_password", + schema: "invoice_reminder", + columns: ["id", "user_id", "password_hash", "password_salt", "created_at", "updated_at"], + values: new object[,] + { + { + "7ac11c2e-eb20-4eba-9aa2-b1ff7f66c534", + "0d77a03d-ac35-480c-b409-a08133409c7c", + "A1jP0sC5JeSdn9+FMlmf0hATixlmtPyKnTYSkyGq44I=", + "jGw9jaT87q29CxlBTTpTQw==", + new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc) + }, + { + "ceb8ed4f-88ef-43c5-9ddd-2f3bd03dd01b", + "59918776-f6c6-4def-93b1-95d7a7717942", + "epSsrxJtxqrON9hTo9TONf7o4abblXl2E9hnGQojdNA=", + "Cj4bofNoOjj6aDwp4Sbq1w==", new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc), new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc) } @@ -186,11 +231,18 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "user_id"); _ = migrationBuilder.CreateIndex( - name: "idx_user_email", + name: "IX_user_email", schema: "invoice_reminder", table: "user", column: "email", unique: true); + + _ = migrationBuilder.CreateIndex( + name: "IX_user_password_user_id", + schema: "invoice_reminder", + table: "user_password", + column: "user_id", + unique: true); } /// @@ -212,6 +264,10 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "scan_email_definition", schema: "invoice_reminder"); + _ = migrationBuilder.DropTable( + name: "user_password", + schema: "invoice_reminder"); + _ = migrationBuilder.DropTable( name: "user", schema: "invoice_reminder"); diff --git a/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs b/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs index 0d9282f..92eb289 100644 --- a/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs +++ b/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs @@ -1,17 +1,15 @@ -// +// +using System; using InvoiceReminder.Data.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using System; -using System.Diagnostics.CodeAnalysis; #nullable disable namespace InvoiceReminder.Data.Migrations { - [ExcludeFromCodeCoverage] [DbContext(typeof(CoreDbContext))] partial class CoreDbContextModelSnapshot : ModelSnapshot { @@ -43,7 +41,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("access_token_expiry"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -66,7 +63,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("token_provider"); b.Property("UpdatedAt") - .ValueGeneratedOnAddOrUpdate() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -110,7 +106,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("beneficiary"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -119,7 +114,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("due_date"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -142,7 +136,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -153,7 +146,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("cron_expression"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -188,7 +180,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("beneficiary"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -209,7 +200,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("sender_email_address"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -232,7 +222,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("CreatedAt") - .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -248,12 +237,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(255)") .HasColumnName("name"); - b.Property("Password") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("password"); - b.Property("TelegramChatId") .ValueGeneratedOnAdd() .HasColumnType("bigint") @@ -261,7 +244,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("telegram_chat_id"); b.Property("UpdatedAt") - .ValueGeneratedOnUpdate() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -269,11 +251,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Email") .IsUnique() - .HasDatabaseName("idx_user_email"); + .HasDatabaseName("IX_user_email"); b.ToTable("user", "invoice_reminder"); }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("password_hash"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("password_salt"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_password", "invoice_reminder"); + }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.EmailAuthToken", b => { b.HasOne("InvoiceReminder.Domain.Entities.User", null) @@ -310,6 +331,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b => + { + b.HasOne("InvoiceReminder.Domain.Entities.User", null) + .WithOne("UserPassword") + .HasForeignKey("InvoiceReminder.Domain.Entities.UserPassword", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("InvoiceReminder.Domain.Entities.User", b => { b.Navigation("EmailAuthTokens"); @@ -319,6 +349,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("JobSchedules"); b.Navigation("ScanEmailDefinitions"); + + b.Navigation("UserPassword"); }); #pragma warning restore 612, 618 } diff --git a/InvoiceReminder.Data/Persistence/CoreDbContext.cs b/InvoiceReminder.Data/Persistence/CoreDbContext.cs index bdd6c2e..9e4e972 100644 --- a/InvoiceReminder.Data/Persistence/CoreDbContext.cs +++ b/InvoiceReminder.Data/Persistence/CoreDbContext.cs @@ -8,6 +8,7 @@ public class CoreDbContext : DbContext public DbSet Users => Set(); public DbSet Invoices => Set(); public DbSet Schedules => Set(); + public DbSet UserPasswords => Set(); public DbSet EmailAuthTokens => Set(); public DbSet ScanEmailDefinitions => Set(); diff --git a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs index d870d57..0c43c73 100644 --- a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs +++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs @@ -16,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) _ = builder.HasKey(x => x.Id); _ = builder.HasIndex(x => x.Email) - .HasDatabaseName("idx_user_email") + .HasDatabaseName("IX_user_email") .IsUnique(); _ = builder.Property(x => x.Id) @@ -41,11 +41,6 @@ public void Configure(EntityTypeBuilder builder) .HasMaxLength(255) .IsRequired(); - _ = builder.Property(x => x.Password) - .HasColumnName("password") - .HasMaxLength(255) - .IsRequired(); - _ = builder.Property(x => x.CreatedAt) .HasColumnName("created_at") .HasColumnType("timestamp with time zone") diff --git a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs new file mode 100644 index 0000000..5f9f4de --- /dev/null +++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs @@ -0,0 +1,49 @@ +using InvoiceReminder.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("InvoiceReminder.UnitTests.Infrastructure")] + +namespace InvoiceReminder.Data.Persistence.EntitiesConfig; + +internal class UserPasswordConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + _ = builder.ToTable("user_password"); + + _ = builder.HasKey(x => x.Id); + + _ = builder.Property(x => x.Id) + .HasColumnName("id") + .HasColumnType("uuid") + .ValueGeneratedOnAdd() + .IsRequired(); + + _ = builder.Property(x => x.UserId) + .HasColumnName("user_id") + .HasColumnType("uuid") + .IsRequired(); + + _ = builder.Property(x => x.PasswordHash) + .HasColumnName("password_hash") + .HasMaxLength(512) + .IsRequired(); + + _ = builder.Property(x => x.PasswordSalt) + .HasColumnName("password_salt") + .HasMaxLength(256) + .IsRequired(); + + _ = builder.Property(x => x.CreatedAt) + .HasColumnName("created_at") + .HasColumnType("timestamp with time zone") + .IsRequired(); + + _ = builder.Property(x => x.UpdatedAt) + .HasColumnName("updated_at") + .HasColumnType("timestamp with time zone") + .IsRequired(); + } +} diff --git a/InvoiceReminder.Data/Repository/UserPasswordRepository.cs b/InvoiceReminder.Data/Repository/UserPasswordRepository.cs new file mode 100644 index 0000000..9828bd5 --- /dev/null +++ b/InvoiceReminder.Data/Repository/UserPasswordRepository.cs @@ -0,0 +1,62 @@ +using Dapper; +using InvoiceReminder.Data.Exceptions; +using InvoiceReminder.Data.Interfaces; +using InvoiceReminder.Data.Persistence; +using InvoiceReminder.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Data; + +namespace InvoiceReminder.Data.Repository; + +public class UserPasswordRepository : BaseRepository, IUserPasswordRepository +{ + private readonly IDbConnection _dbConnection; + private readonly ILogger _logger; + private const string LogExceptionMessage = "{ContextualInfo} - Exception: {Message}"; + + public UserPasswordRepository(CoreDbContext dbContext, ILogger logger) : base(dbContext) + { + _dbConnection = dbContext.Database.GetDbConnection(); + _logger = logger; + } + + public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + UserPassword userPassword = default; + + try + { + var query = @"select * from invoice_reminder.user_password up where up.user_id = @userid"; + var command = new CommandDefinition(query, new { userId }, cancellationToken: cancellationToken); + + userPassword = await _dbConnection.QueryFirstOrDefaultAsync(command); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + var method = $"{nameof(UserPasswordRepository)}.{nameof(GetByUserIdAsync)}"; + var contextualInfo = $"Method {method} execution was interrupted by a CancellationToken Request..."; + + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning(ex, LogExceptionMessage, contextualInfo, ex.Message); + } + + throw new OperationCanceledException(contextualInfo, ex, cancellationToken); + } + catch (Exception ex) + { + var method = $"{nameof(UserPasswordRepository)}.{nameof(GetByUserIdAsync)}"; + var contextualInfo = $"Exception raised while querying DB >> {method}(...)"; + + if (_logger.IsEnabled(LogLevel.Error)) + { + _logger.LogError(ex, LogExceptionMessage, contextualInfo, ex.Message); + } + + throw new DataLayerException(contextualInfo, ex); + } + + return userPassword; + } +} diff --git a/InvoiceReminder.Data/Repository/UserRepository.cs b/InvoiceReminder.Data/Repository/UserRepository.cs index 245d09e..0c2b93e 100644 --- a/InvoiceReminder.Data/Repository/UserRepository.cs +++ b/InvoiceReminder.Data/Repository/UserRepository.cs @@ -27,6 +27,8 @@ left join invoice_reminder.invoice i on u.id = i.user_id left join invoice_reminder.job_schedule js on u.id = js.user_id + left join invoice_reminder.user_password up + on u.id = up.user_id left join invoice_reminder.email_auth_token eat on u.id = eat.user_id left join invoice_reminder.scan_email_definition sed @@ -43,13 +45,14 @@ public async Task GetByEmailAsync(string value, CancellationToken cancella var filter = "where u.email = @value"; var command = new CommandDefinition($"{_query} {filter}", new { value }, cancellationToken: cancellationToken); - _ = await _dbConnection.QueryAsync - (command, (user, invoice, jobschedule, emailAuthToken, scanEmailDefinition) => + _ = await _dbConnection.QueryAsync + (command, (user, invoice, jobschedule, userPassword, emailAuthToken, scanEmailDefinition) => { var parameters = new UserParameters { Invoice = invoice, JobSchedule = jobschedule, + UserPassword = userPassword, EmailAuthToken = emailAuthToken, ScanEmailDefinition = scanEmailDefinition }; @@ -97,13 +100,14 @@ public override async Task GetByIdAsync(Guid id, CancellationToken cancell var filter = "where u.id = @id"; var command = new CommandDefinition($"{_query} {filter}", new { id }, cancellationToken: cancellationToken); - _ = await _dbConnection.QueryAsync( - command, (user, invoice, jobschedule, emailAuthToken, scanEmailDefinition) => + _ = await _dbConnection.QueryAsync( + command, (user, invoice, jobschedule, userPassword, emailAuthToken, scanEmailDefinition) => { var parameters = new UserParameters { Invoice = invoice, JobSchedule = jobschedule, + UserPassword = userPassword, EmailAuthToken = emailAuthToken, ScanEmailDefinition = scanEmailDefinition }; diff --git a/InvoiceReminder.Domain/Entities/User.cs b/InvoiceReminder.Domain/Entities/User.cs index fbf08a9..f37b62b 100644 --- a/InvoiceReminder.Domain/Entities/User.cs +++ b/InvoiceReminder.Domain/Entities/User.cs @@ -5,7 +5,7 @@ public class User : EntityDefaults public long TelegramChatId { get; set; } public string Name { get; set; } public string Email { get; set; } - public string Password { get; set; } + public virtual UserPassword UserPassword { get; set; } public virtual ICollection EmailAuthTokens { get; set; } public virtual ICollection Invoices { get; set; } public virtual ICollection JobSchedules { get; set; } diff --git a/InvoiceReminder.Domain/Entities/UserPassword.cs b/InvoiceReminder.Domain/Entities/UserPassword.cs new file mode 100644 index 0000000..5d2439d --- /dev/null +++ b/InvoiceReminder.Domain/Entities/UserPassword.cs @@ -0,0 +1,8 @@ +namespace InvoiceReminder.Domain.Entities; + +public class UserPassword : EntityDefaults +{ + public Guid UserId { get; set; } + public string PasswordHash { get; set; } + public string PasswordSalt { get; set; } +} diff --git a/InvoiceReminder.Domain/Extensions/UserExtensions.cs b/InvoiceReminder.Domain/Extensions/UserExtensions.cs index abf484f..a407321 100644 --- a/InvoiceReminder.Domain/Extensions/UserExtensions.cs +++ b/InvoiceReminder.Domain/Extensions/UserExtensions.cs @@ -11,6 +11,7 @@ public static IDictionary Handle( { if (!result.TryGetValue(user.Id, out var existingUser)) { + user.UserPassword = parameters.UserPassword; parameters.Invoice.AddIfNotExists(user.Invoices); parameters.JobSchedule.AddIfNotExists(user.JobSchedules); parameters.EmailAuthToken.AddIfNotExists(user.EmailAuthTokens); @@ -20,6 +21,7 @@ public static IDictionary Handle( } else { + existingUser.UserPassword = parameters.UserPassword; parameters.Invoice.AddIfNotExists(existingUser.Invoices); parameters.JobSchedule.AddIfNotExists(existingUser.JobSchedules); parameters.EmailAuthToken.AddIfNotExists(existingUser.EmailAuthTokens); diff --git a/InvoiceReminder.Domain/Extensions/UserParameters.cs b/InvoiceReminder.Domain/Extensions/UserParameters.cs index 5be54f3..bb15867 100644 --- a/InvoiceReminder.Domain/Extensions/UserParameters.cs +++ b/InvoiceReminder.Domain/Extensions/UserParameters.cs @@ -6,6 +6,7 @@ public record UserParameters { public Invoice Invoice { get; set; } public JobSchedule JobSchedule { get; set; } + public UserPassword UserPassword { get; set; } public ScanEmailDefinition ScanEmailDefinition { get; set; } public EmailAuthToken EmailAuthToken { get; set; } } diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs index a2d582c..9c3fd1e 100644 --- a/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs +++ b/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs @@ -590,7 +590,7 @@ public void Dispose_Should_Be_Safe_To_Call_Multiple_Times() #region Helper Methods - private BaseRepository CreateFreshRepository() where T : class + private static BaseRepository CreateFreshRepository() where T : class { var options = new DbContextOptionsBuilder() .UseNpgsql(DatabaseFixture.ConnectionString) diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs index 191de0b..add15b8 100644 --- a/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs +++ b/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs @@ -48,8 +48,7 @@ private static Faker UserFaker() .RuleFor(u => u.Id, _ => Guid.NewGuid()) .RuleFor(u => u.TelegramChatId, f => f.Random.Long(100000000, long.MaxValue)) .RuleFor(u => u.Name, f => f.Person.FullName) - .RuleFor(u => u.Email, f => f.Internet.Email()) - .RuleFor(u => u.Password, f => f.Internet.Password(length: 16, memorable: false)); + .RuleFor(u => u.Email, f => f.Internet.Email()); } #endregion diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs new file mode 100644 index 0000000..e6ae894 --- /dev/null +++ b/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs @@ -0,0 +1,248 @@ +using InvoiceReminder.Data.Exceptions; +using InvoiceReminder.Data.Interfaces; +using InvoiceReminder.Data.Persistence; +using InvoiceReminder.Data.Repository; +using InvoiceReminder.Domain.Entities; +using InvoiceReminder.IntegrationTests.Data.ContainerSetup; +using InvoiceReminder.IntegrationTests.Data.Utils; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; + +namespace InvoiceReminder.IntegrationTests.Data.Repository; + +[TestClass] +public sealed class UserPasswordRepositoryIntegrationTests +{ + private readonly CoreDbContext _dbContext; + private readonly ILogger _repositoryLogger; + private readonly ILogger _unitOfWorkLogger; + private readonly UserPasswordRepository _repository; + private readonly UnitOfWork _unitOfWork; + + public TestContext TestContext { get; set; } + + public UserPasswordRepositoryIntegrationTests() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(DatabaseFixture.ConnectionString) + .Options; + + _dbContext = new CoreDbContext(options); + _repositoryLogger = Substitute.For>(); + _unitOfWorkLogger = Substitute.For>(); + _repository = new UserPasswordRepository(_dbContext, _repositoryLogger); + _unitOfWork = new UnitOfWork(_dbContext, _unitOfWorkLogger); + } + + [TestCleanup] + public void TestCleanup() + { + _unitOfWork?.Dispose(); + _repository?.Dispose(); + _dbContext?.Dispose(); + } + + [TestMethod] + public void UserPasswordRepository_ShouldBeAssignableToItsInterface_And_GenericInterface_And_GenericRepository() + { + // Arrange && Act + var repository = new UserPasswordRepository(_dbContext, _repositoryLogger); + + // Assert + repository.ShouldSatisfyAllConditions(() => + { + _ = repository.ShouldBeAssignableTo(); + _ = repository.ShouldBeAssignableTo>(); + _ = repository.ShouldBeAssignableTo>(); + + _ = repository.ShouldNotBeNull(); + _ = repository.ShouldBeOfType(); + }); + } + + #region GetByIdAsync Tests + + [TestMethod] + public async Task GetByIdAsync_Should_Return_UserPassword_By_Id() + { + // Arrange + var userPassword = await CreateAndSaveUserPasswordAsync(); + + // Act + var result = await _repository.GetByIdAsync(userPassword.Id, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + result.Id.ShouldBe(userPassword.Id); + }); + } + + [TestMethod] + public async Task GetByIdAsync_Should_Return_Null_For_NonExistent_UserPassword() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var result = await _repository.GetByIdAsync(nonExistentId, TestContext.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + public async Task GetByIdAsync_Should_Throw_Exception_On_Database_Error() + { + // Arrange + var disposedContext = new CoreDbContext(new DbContextOptionsBuilder() + .UseNpgsql(DatabaseFixture.ConnectionString) + .Options); + + var logger = Substitute.For>(); + + var repository = new UserPasswordRepository(disposedContext, logger); + + // Act & Assert + await disposedContext.DisposeAsync(); + + _ = await Should.ThrowAsync( + async () => await repository.GetByIdAsync(Guid.NewGuid(), TestContext.CancellationToken) + ); + } + + #endregion + + #region GetByUserIdAsync Tests + + [TestMethod] + public async Task GetByUserIdAsync_Should_Return_UserPassword_By_UserId() + { + // Arrange + var userPassword = await CreateAndSaveUserPasswordAsync(); + + // Act + var result = await _repository.GetByUserIdAsync(userPassword.UserId, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + result.UserId.ShouldBe(userPassword.UserId); + result.PasswordHash.ShouldBe(userPassword.PasswordHash); + result.PasswordSalt.ShouldBe(userPassword.PasswordSalt); + }); + } + + [TestMethod] + public async Task GetByUserIdAsync_Should_Return_Null_For_NonExistent_UserId() + { + // Arrange + var nonExistentUserId = Guid.NewGuid(); + + // Act + var result = await _repository.GetByUserIdAsync(nonExistentUserId, TestContext.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + public async Task GetByUserIdAsync_Should_Throw_Exception_On_Database_Error() + { + // Arrange + var disposedContext = new CoreDbContext(new DbContextOptionsBuilder() + .UseNpgsql(DatabaseFixture.ConnectionString) + .Options); + + var logger = Substitute.For>(); + + var repository = new UserPasswordRepository(disposedContext, logger); + + _ = logger.IsEnabled(Arg.Any()).Returns(true); + + // Act & Assert + await disposedContext.DisposeAsync(); + + _ = await Should.ThrowAsync( + async () => await repository.GetByUserIdAsync(Guid.NewGuid(), TestContext.CancellationToken) + ); + + logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + } + + #endregion + + #region CancellationToken Tests + + [TestMethod] + public async Task GetByUserIdAsync_Should_Handle_Cancellation_Request() + { + // Arrange + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + _ = _repositoryLogger.IsEnabled(Arg.Any()).Returns(true); + + // Act & Assert + _ = await Should.ThrowAsync( + async () => await _repository.GetByUserIdAsync(Guid.NewGuid(), cts.Token) + ); + + _repositoryLogger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + } + + #endregion + + #region Helper Methods + + private async Task CreateAndSaveUserPasswordAsync(User user = null, UserPassword userPassword = null) + { + user ??= await CreateAndSaveUserAsync(); + userPassword ??= new UserPassword + { + Id = Guid.NewGuid(), + UserId = user.Id, + PasswordHash = "hash_" + Guid.NewGuid().ToString(), + PasswordSalt = "salt_" + Guid.NewGuid().ToString(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _ = await _repository.AddAsync(userPassword, TestContext.CancellationToken); + await _unitOfWork.SaveChangesAsync(TestContext.CancellationToken); + + return userPassword; + } + + private async Task CreateAndSaveUserAsync(User user = null) + { + user ??= TestData.UserFaker().Generate(); + var logger = Substitute.For>(); + var userRepository = new UserRepository(_dbContext, logger); + + _ = await userRepository.AddAsync(user, TestContext.CancellationToken); + await _unitOfWork.SaveChangesAsync(TestContext.CancellationToken); + + return user; + } + + #endregion +} diff --git a/InvoiceReminder.IntegrationTests/Data/Utils/TestData.cs b/InvoiceReminder.IntegrationTests/Data/Utils/TestData.cs index 67a0bc2..25254f3 100644 --- a/InvoiceReminder.IntegrationTests/Data/Utils/TestData.cs +++ b/InvoiceReminder.IntegrationTests/Data/Utils/TestData.cs @@ -13,7 +13,6 @@ public static Faker UserFaker() .RuleFor(u => u.TelegramChatId, faker => faker.Random.Long(100000000, long.MaxValue)) .RuleFor(u => u.Name, faker => faker.Person.FullName) .RuleFor(u => u.Email, faker => faker.Internet.Email()) - .RuleFor(u => u.Password, faker => faker.Internet.Password(length: 16, memorable: false)) .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) .RuleFor(e => e.UpdatedAt, (faker, u) => faker.Date.Between(u.CreatedAt, DateTime.UtcNow).ToUniversalTime()); } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs index 2720c13..99cc735 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs @@ -2,7 +2,7 @@ using InvoiceReminder.API.AuthenticationSetup; using InvoiceReminder.Application.Interfaces; using InvoiceReminder.Application.ViewModels; -using InvoiceReminder.Authentication.Extensions; +using InvoiceReminder.Authentication.Abstractions; using InvoiceReminder.Authentication.Interfaces; using InvoiceReminder.Authentication.Jwt; using InvoiceReminder.Domain.Abstractions; @@ -45,7 +45,6 @@ public LoginEndpointTests() .RuleFor(u => u.Id, faker => faker.Random.Guid()) .RuleFor(u => u.Name, faker => faker.Person.FullName) .RuleFor(u => u.Email, faker => faker.Internet.Email()) - .RuleFor(u => u.Password, faker => faker.Internet.Password().ToSHA256()) .RuleFor(u => u.TelegramChatId, faker => faker.Random.Long(1)) .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()); @@ -64,7 +63,6 @@ public async Task Login_WithValidCredentials_ReturnsOkWithJwtObject() var expectedUser = _userViewModelFaker .Clone() - .RuleFor(u => u.Password, password.ToSHA256()) .Generate(); var loginRequest = new LoginRequest @@ -80,10 +78,10 @@ public async Task Login_WithValidCredentials_ReturnsOkWithJwtObject() Expiration = DateTime.UtcNow.AddMinutes(60) }; - _ = _userAppService.GetByEmailAsync(Arg.Any(), Arg.Any()) + _ = _userAppService.ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(expectedUser)); - _ = _jwtProvider.Generate(Arg.Any()).Returns(expectedJwtObject); + _ = _jwtProvider.Generate(Arg.Any()).Returns(expectedJwtObject); // Act request.Content = JsonContent.Create(loginRequest); @@ -91,8 +89,10 @@ public async Task Login_WithValidCredentials_ReturnsOkWithJwtObject() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.Received(1).GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.Received(1).Generate(Arg.Any()); + _ = _userAppService.Received(1) + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + _ = _jwtProvider.Received(1).Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.OK); @@ -118,7 +118,7 @@ public async Task Login_WithInvalidEmail_ReturnsUnauthorized() AuthenticationToken = null }; - _ = _userAppService.GetByEmailAsync(Arg.Any(), Arg.Any()) + _ = _userAppService.ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure("User not found")); // Act @@ -127,8 +127,10 @@ public async Task Login_WithInvalidEmail_ReturnsUnauthorized() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.Received(1).GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); + _ = _userAppService.Received(1) + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); @@ -146,11 +148,9 @@ public async Task Login_WithInvalidPassword_ReturnsUnauthorized() // Arrange var request = new HttpRequestMessage(HttpMethod.Post, basepath); - var expectedUser = _userViewModelFaker.Generate(); - var loginRequest = new LoginRequest { - Email = expectedUser.Email, + Email = _faker.Internet.Email(), Password = _faker.Internet.Password() }; @@ -160,8 +160,8 @@ public async Task Login_WithInvalidPassword_ReturnsUnauthorized() AuthenticationToken = null }; - _ = _userAppService.GetByEmailAsync(Arg.Any(), Arg.Any()) - .Returns(Result.Success(expectedUser)); + _ = _userAppService.ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Failure("User not Found.")); // Act request.Content = JsonContent.Create(loginRequest); @@ -169,8 +169,10 @@ public async Task Login_WithInvalidPassword_ReturnsUnauthorized() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.Received(1).GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); + _ = _userAppService.Received(1) + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); @@ -201,7 +203,7 @@ public async Task Login_WithEmptyEmailOrPassword_ShouldReturnBadRequest() // Assert _ = _userAppService.DidNotReceive().GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); @@ -221,7 +223,7 @@ public async Task Login_WhenServiceFails_ShouldReturnInternalServerError() var loginRequest = _loginRequestFaker.Generate(); - _ = _userAppService.GetByEmailAsync(Arg.Any(), Arg.Any()) + _ = _userAppService.ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Service error")); // Act @@ -230,8 +232,10 @@ public async Task Login_WhenServiceFails_ShouldReturnInternalServerError() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.Received(1).GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); + _ = _userAppService.Received(1) + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); diff --git a/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs index 25e1c37..b0e8a7e 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs @@ -43,7 +43,6 @@ public UserEndpointsTests() .RuleFor(u => u.Id, faker => faker.Random.Guid()) .RuleFor(u => u.Name, faker => faker.Person.FullName) .RuleFor(u => u.Email, faker => faker.Internet.Email()) - .RuleFor(u => u.Password, faker => faker.Internet.Password()) .RuleFor(u => u.TelegramChatId, faker => faker.Random.Long(1)) .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()); diff --git a/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs new file mode 100644 index 0000000..10522c8 --- /dev/null +++ b/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs @@ -0,0 +1,371 @@ +using Bogus; +using InvoiceReminder.Application.Interfaces; +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Domain.Abstractions; +using InvoiceReminder.UnitTests.API.Factories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; + +namespace InvoiceReminder.UnitTests.API.Endpoints; + +[TestClass] +public sealed class UserPasswordEndpointsTests +{ + private readonly HttpClient _client; + private readonly IAuthorizationService _authorizationService; + private readonly IUserPasswordAppService _userPasswordAppService; + private readonly Faker _userPasswordViewModelFaker; + private const string basepath = "/api/user_password"; + + public TestContext TestContext { get; set; } + + public UserPasswordEndpointsTests() + { + var factory = new CustomWebApplicationFactory(); + var serviceProvider = factory.Services; + + _client = factory.CreateClient(); + _authorizationService = serviceProvider.GetRequiredService(); + _userPasswordAppService = serviceProvider.GetRequiredService(); + + _userPasswordViewModelFaker = new Faker() + .RuleFor(u => u.Id, faker => faker.Random.Guid()) + .RuleFor(u => u.UserId, faker => faker.Random.Guid()) + .RuleFor(u => u.PasswordHash, faker => faker.Internet.Password(12, false, "[A-Z]", "abc123")) + .RuleFor(u => u.PasswordSalt, faker => faker.Random.AlphaNumeric(24)) + .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) + .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()); + } + + #region MapPost Tests - CreateUserPassword + + [TestMethod] + public async Task CreateUserPassword_WhenUserIsAuthenticated_ShouldReturnCreated() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + var expectedResult = Result.Success(userPasswordViewModel); + + _ = _userPasswordAppService.AddAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).AddAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.Created); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + [TestMethod] + public async Task CreateUserPassword_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, basepath); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Failed())); + + // Act + var response = await _client.SendAsync(request, TestContext.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [TestMethod] + public async Task CreateUserPassword_WhenUserIsAuthenticatedButServiceFails_ShouldReturnInternalServerError() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + var expectedResult = Result.Failure("Service error"); + + _ = _userPasswordAppService.AddAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).AddAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + #endregion + + #region MapPost Tests - BulkCreateUserPassword + + [TestMethod] + public async Task BulkCreateUserPassword_WhenUserIsAuthenticated_ShouldReturnCreated() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, $"{basepath}/bulk-insert"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModels = _userPasswordViewModelFaker.Generate(2); + var expectedResult = Result.Success(userPasswordViewModels.Count); + + _ = _userPasswordAppService.BulkInsertAsync(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModels); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = int.Parse(await response.Content.ReadAsStringAsync(TestContext.CancellationToken)); + + // Assert + _ = _userPasswordAppService.Received(1).BulkInsertAsync(Arg.Any>(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.Created); + result.ShouldBeGreaterThan(0); + _ = result.ShouldBeOfType(); + } + + [TestMethod] + public async Task BulkCreateUserPassword_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, $"{basepath}/bulk-insert"); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Failed())); + + // Act + var response = await _client.SendAsync(request, TestContext.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [TestMethod] + public async Task BulkCreateUserPassword_WhenUserIsAuthenticatedButServiceFails_ShouldReturnInternalServerError() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, $"{basepath}/bulk-insert"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModels = _userPasswordViewModelFaker.Generate(2); + var expectedResult = Result.Failure("Service error"); + + _ = _userPasswordAppService.BulkInsertAsync(Arg.Any>(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModels); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).BulkInsertAsync(Arg.Any>(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + #endregion + + #region MapPut Tests - UpdateUserPassword + + [TestMethod] + public async Task UpdateUserPassword_WhenUserIsAuthenticated_ShouldReturnOk() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + var expectedResult = Result.Success(userPasswordViewModel); + + _ = _userPasswordAppService.UpdateAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + [TestMethod] + public async Task UpdateUserPassword_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, basepath); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Failed())); + + // Act + var response = await _client.SendAsync(request, TestContext.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [TestMethod] + public async Task UpdateUserPassword_WhenUserIsAuthenticatedButServiceFails_ShouldReturnInternalServerError() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + var expectedResult = Result.Failure("Service error"); + + _ = _userPasswordAppService.UpdateAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + #endregion + + #region MapDelete Tests - DeleteUserPassword + + [TestMethod] + public async Task DeleteUserPassword_WhenUserIsAuthenticated_ShouldReturnNoContent() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Delete, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var expectedResult = Result.Success(null); + + _ = _userPasswordAppService.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadAsStringAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).RemoveAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.NoContent); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + [TestMethod] + public async Task DeleteUserPassword_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Delete, basepath); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Failed())); + + // Act + var response = await _client.SendAsync(request, TestContext.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [TestMethod] + public async Task DeleteUserPassword_WhenUserIsAuthenticatedButServiceFails_ShouldReturnInternalServerError() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Delete, basepath); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); + + var expectedResult = Result.Failure("Service error"); + + _ = _userPasswordAppService.RemoveAsync(Arg.Any(), Arg.Any()) + .Returns(expectedResult); + + _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()) + .Returns(Task.FromResult(AuthorizationResult.Success())); + + // Act + var userPasswordViewModel = _userPasswordViewModelFaker.Generate(); + request.Content = JsonContent.Create(userPasswordViewModel); + var response = await _client.SendAsync(request, TestContext.CancellationToken); + var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); + + // Assert + _ = _userPasswordAppService.Received(1).RemoveAsync(Arg.Any(), Arg.Any()); + + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + } + + #endregion +} diff --git a/InvoiceReminder.UnitTests.API/Factories/CustomWebApplicationFactory.cs b/InvoiceReminder.UnitTests.API/Factories/CustomWebApplicationFactory.cs index 6c1dc51..2e2ff76 100644 --- a/InvoiceReminder.UnitTests.API/Factories/CustomWebApplicationFactory.cs +++ b/InvoiceReminder.UnitTests.API/Factories/CustomWebApplicationFactory.cs @@ -28,6 +28,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) _ = services.RemoveAll(); _ = services.RemoveAll(); _ = services.RemoveAll(); + _ = services.RemoveAll(); _ = services.RemoveAll(); _ = services.AddSingleton(Substitute.For()); @@ -37,6 +38,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) _ = services.AddSingleton(Substitute.For()); _ = services.AddSingleton(Substitute.For()); _ = services.AddSingleton(Substitute.For()); + _ = services.AddSingleton(Substitute.For()); _ = services.AddSingleton(Substitute.For()); }); } diff --git a/InvoiceReminder.UnitTests.Application/AppServices/BaseAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/BaseAppServiceTests.cs index ab95bcd..50ef411 100644 --- a/InvoiceReminder.UnitTests.Application/AppServices/BaseAppServiceTests.cs +++ b/InvoiceReminder.UnitTests.Application/AppServices/BaseAppServiceTests.cs @@ -92,7 +92,7 @@ public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null { result.IsSuccess.ShouldBeFalse(); result.Value.ShouldBe(0); - result.Error.ShouldBe("Parameter viewModels was Null or Empty."); + result.Error.ShouldBe("Parameter viewModelCollection was Null or Empty."); }); } diff --git a/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs index 09af49e..5346cc2 100644 --- a/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs @@ -32,7 +32,6 @@ private static Faker CreateFaker() .RuleFor(u => u.Id, faker => faker.Random.Guid()) .RuleFor(u => u.Name, faker => faker.Person.FullName) .RuleFor(u => u.Email, faker => faker.Internet.Email()) - .RuleFor(u => u.Password, faker => faker.Internet.Password()) .RuleFor(u => u.TelegramChatId, faker => faker.Random.Long(100000000, long.MaxValue)) .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()) diff --git a/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs new file mode 100644 index 0000000..a0343f4 --- /dev/null +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs @@ -0,0 +1,355 @@ +using Bogus; +using InvoiceReminder.Application.AppServices; +using InvoiceReminder.Application.Interfaces; +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Data.Interfaces; +using InvoiceReminder.Domain.Entities; +using Mapster; +using NSubstitute; +using Shouldly; + +namespace InvoiceReminder.UnitTests.Application.AppServices; + +[TestClass] +public sealed class UserPasswordAppServiceTests +{ + private readonly IUserPasswordRepository _repository; + private readonly IUnitOfWork _unitOfWork; + private readonly Faker _faker; + + public TestContext TestContext { get; set; } + + public UserPasswordAppServiceTests() + { + _repository = Substitute.For(); + _unitOfWork = Substitute.For(); + _faker = new Faker(); + } + + private static Faker CreateUserPasswordFaker() + { + return new Faker() + .RuleFor(u => u.Id, faker => faker.Random.Guid()) + .RuleFor(u => u.UserId, faker => faker.Random.Guid()) + .RuleFor(u => u.PasswordHash, faker => faker.Random.AlphaNumeric(88)) + .RuleFor(u => u.PasswordSalt, faker => faker.Random.AlphaNumeric(24)) + .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) + .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()); + } + + private static Faker CreateUserPasswordViewModelFaker() + { + return new Faker() + .RuleFor(u => u.Id, faker => faker.Random.Guid()) + .RuleFor(u => u.UserId, faker => faker.Random.Guid()) + .RuleFor(u => u.PasswordHash, faker => faker.Internet.Password(12, false, "[A-Z]", "abc123")) + .RuleFor(u => u.PasswordSalt, faker => faker.Random.AlphaNumeric(24)) + .RuleFor(u => u.CreatedAt, faker => faker.Date.Past().ToUniversalTime()) + .RuleFor(u => u.UpdatedAt, faker => faker.Date.Recent().ToUniversalTime()); + } + + [TestMethod] + public void UserPasswordAppService_ShouldBeAssignableToItsInterface_And_GenericInterface_And_GenericAppService() + { + // Arrange && Act + var appService = new UserPasswordAppService(_repository, _unitOfWork); + + // Assert + appService.ShouldSatisfyAllConditions(() => + { + _ = appService.ShouldBeAssignableTo(); + _ = appService.ShouldNotBeNull(); + _ = appService.ShouldBeOfType(); + }); + } + + #region AddAsync Tests + + [TestMethod] + public async Task AddAsync_Should_Hash_Password_Before_Adding() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var viewModel = CreateUserPasswordViewModelFaker().Generate(); + var plainPassword = viewModel.PasswordHash; + + _ = _repository.AddAsync(Arg.Any(), Arg.Any()) + .Returns(viewModel.Adapt()); + + _ = _unitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + var result = await appService.AddAsync(viewModel, TestContext.CancellationToken); + + // Assert + _ = _repository.Received(1).AddAsync(Arg.Any(), Arg.Any()); + _ = _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeTrue(); + _ = result.Value.ShouldNotBeNull(); + _ = result.Value.ShouldBeOfType(); + result.Value.UserId.ShouldBe(viewModel.UserId); + // Verify that password was hashed (different from original) + result.Value.PasswordHash.ShouldNotBe(plainPassword); + }); + } + + [TestMethod] + public async Task AddAsync_Should_Return_Failure_When_ViewModel_Is_Null() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + + // Act + var result = await appService.AddAsync(null, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeFalse(); + result.Value.ShouldBeNull(); + result.Error.ShouldBe("The provided obejct data was Null."); + }); + } + + [TestMethod] + public async Task AddAsync_Should_Generate_Different_Hash_For_Same_Password() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var password = _faker.Internet.Password(12, false, "[A-Z]", "abc123"); + + var viewModel1 = CreateUserPasswordViewModelFaker() + .RuleFor(u => u.PasswordHash, _ => password) + .Generate(); + + var viewModel2 = CreateUserPasswordViewModelFaker() + .RuleFor(u => u.PasswordHash, _ => password) + .Generate(); + + _ = _repository.AddAsync(Arg.Any(), Arg.Any()) + .Returns(x => x.Arg()); + + _ = _unitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + var result1 = await appService.AddAsync(viewModel1, TestContext.CancellationToken); + var result2 = await appService.AddAsync(viewModel2, TestContext.CancellationToken); + + // Assert + result1.ShouldSatisfyAllConditions(() => + { + result1.IsSuccess.ShouldBeTrue(); + result2.IsSuccess.ShouldBeTrue(); + // Argon2id should produce different hashes even for the same password + result1.Value.PasswordHash.ShouldNotBe(result2.Value.PasswordHash); + }); + } + + #endregion + + #region BulkInsertAsync Tests + + [TestMethod] + public async Task BulkInsertAsync_Should_Hash_All_Passwords_Before_Inserting() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var viewModels = CreateUserPasswordViewModelFaker().Generate(3).ToList(); + var plainPasswords = viewModels.Select(v => v.PasswordHash).ToList(); + + _ = _repository.BulkInsertAsync(Arg.Any>(), Arg.Any()) + .Returns(3); + + // Act + var result = await appService.BulkInsertAsync(viewModels, TestContext.CancellationToken); + + // Assert + _ = _repository.Received(1).BulkInsertAsync(Arg.Any>(), Arg.Any()); + + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe(3); + // All original passwords should have been hashed (changed) + viewModels.All(v => plainPasswords.Contains(v.PasswordHash)).ShouldBeFalse(); + }); + } + + [TestMethod] + public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + + // Act + var result = await appService.BulkInsertAsync(null, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeFalse(); + result.Value.ShouldBe(0); + result.Error.ShouldBe("The provided obejct data was Null or Empty."); + }); + } + + [TestMethod] + public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Empty() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + + // Act + var result = await appService.BulkInsertAsync([], TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeFalse(); + result.Value.ShouldBe(0); + result.Error.ShouldBe("The provided obejct data was Null or Empty."); + }); + } + + #endregion + + #region UpdateAsync Tests + + [TestMethod] + public async Task UpdateAsync_Should_Hash_Password_Before_Updating() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var viewModel = CreateUserPasswordViewModelFaker().Generate(); + var plainPassword = viewModel.PasswordHash; + + _ = _repository.Update(Arg.Any()) + .Returns(viewModel.Adapt()); + + _ = _unitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + var result = await appService.UpdateAsync(viewModel, TestContext.CancellationToken); + + // Assert + _ = _repository.Received(1).Update(Arg.Any()); + _ = _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeTrue(); + _ = result.Value.ShouldNotBeNull(); + _ = result.Value.ShouldBeOfType(); + // Verify that password was hashed (different from original) + result.Value.PasswordHash.ShouldNotBe(plainPassword); + }); + } + + [TestMethod] + public async Task UpdateAsync_Should_Return_Failure_When_ViewModel_Is_Null() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + + // Act + var result = await appService.UpdateAsync(null, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeFalse(); + result.Value.ShouldBeNull(); + result.Error.ShouldBe("The provided obejct data was Null."); + }); + } + + #endregion + + #region GetByUserIdAsync Tests + + [TestMethod] + public async Task GetByUserIdAsync_Should_Return_Success_When_UserPassword_Exists() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var userId = Guid.NewGuid(); + var userPassword = CreateUserPasswordFaker() + .RuleFor(u => u.UserId, _ => userId) + .Generate(); + + _ = _repository.GetByUserIdAsync(Arg.Any(), Arg.Any()) + .Returns(userPassword); + + // Act + var result = await appService.GetByUserIdAsync(userId, TestContext.CancellationToken); + + // Assert + _ = _repository.Received(1).GetByUserIdAsync(Arg.Any(), Arg.Any()); + + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeTrue(); + _ = result.Value.ShouldNotBeNull(); + _ = result.Value.ShouldBeOfType(); + result.Value.UserId.ShouldBe(userId); + result.Value.ShouldBeEquivalentTo(userPassword.Adapt()); + }); + } + + [TestMethod] + public async Task GetByUserIdAsync_Should_Return_Failure_When_UserPassword_NotExists() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var userId = Guid.NewGuid(); + + _ = _repository.GetByUserIdAsync(Arg.Any(), Arg.Any()) + .Returns((UserPassword)null); + + // Act + var result = await appService.GetByUserIdAsync(userId, TestContext.CancellationToken); + + // Assert + _ = _repository.Received(1).GetByUserIdAsync(Arg.Any(), Arg.Any()); + + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeFalse(); + result.Value.ShouldBeNull(); + result.Error.ShouldBe("No user password found for the specified user ID."); + }); + } + + [TestMethod] + public async Task GetByUserIdAsync_Should_Adapt_Entity_To_ViewModel() + { + // Arrange + var appService = new UserPasswordAppService(_repository, _unitOfWork); + var userPassword = CreateUserPasswordFaker().Generate(); + + _ = _repository.GetByUserIdAsync(Arg.Any(), Arg.Any()) + .Returns(userPassword); + + // Act + var result = await appService.GetByUserIdAsync(userPassword.UserId, TestContext.CancellationToken); + + // Assert + result.ShouldSatisfyAllConditions(() => + { + result.IsSuccess.ShouldBeTrue(); + _ = result.Value.ShouldNotBeNull(); + result.Value.Id.ShouldBe(userPassword.Id); + result.Value.UserId.ShouldBe(userPassword.UserId); + result.Value.CreatedAt.ShouldBe(userPassword.CreatedAt); + result.Value.UpdatedAt.ShouldBe(userPassword.UpdatedAt); + }); + } + + #endregion +} diff --git a/InvoiceReminder.UnitTests.Application/ViewModels/UserPasswordViewModelTests.cs b/InvoiceReminder.UnitTests.Application/ViewModels/UserPasswordViewModelTests.cs new file mode 100644 index 0000000..3c427e2 --- /dev/null +++ b/InvoiceReminder.UnitTests.Application/ViewModels/UserPasswordViewModelTests.cs @@ -0,0 +1,17 @@ +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.UnitTests.SUT.Assets; + +namespace InvoiceReminder.UnitTests.Application.ViewModels; + +[TestClass] +public sealed class UserPasswordViewModelTests +{ + [TestMethod] + public void TestProperties() + { + var sut = new UserPasswordViewModel(); + var tester = new PropertyTester(sut); + + tester.TestProperties(); + } +} diff --git a/InvoiceReminder.UnitTests.Domain/Entities/UserPasswordTests.cs b/InvoiceReminder.UnitTests.Domain/Entities/UserPasswordTests.cs new file mode 100644 index 0000000..90487ce --- /dev/null +++ b/InvoiceReminder.UnitTests.Domain/Entities/UserPasswordTests.cs @@ -0,0 +1,17 @@ +using InvoiceReminder.Domain.Entities; +using InvoiceReminder.UnitTests.SUT.Assets; + +namespace InvoiceReminder.UnitTests.Domain.Entities; + +[TestClass] +public sealed class UserPasswordTests +{ + [TestMethod] + public void TestProperties() + { + var sut = new UserPassword(); + var tester = new PropertyTester(sut); + + tester.TestProperties(); + } +} diff --git a/InvoiceReminder.UnitTests.Domain/Extensions/UserExtensionsTests.cs b/InvoiceReminder.UnitTests.Domain/Extensions/UserExtensionsTests.cs index 230bcaa..bfd9c08 100644 --- a/InvoiceReminder.UnitTests.Domain/Extensions/UserExtensionsTests.cs +++ b/InvoiceReminder.UnitTests.Domain/Extensions/UserExtensionsTests.cs @@ -59,7 +59,6 @@ private static Faker CreateFaker( .RuleFor(e => e.Id, faker => faker.Random.Guid()) .RuleFor(e => e.Name, faker => faker.Person.FullName) .RuleFor(e => e.Email, faker => faker.Internet.Email()) - .RuleFor(e => e.Password, faker => faker.Internet.Password()) .RuleFor(e => e.TelegramChatId, faker => faker.Random.Long(1)) .RuleFor(u => u.Invoices, _ => invoices ?? []) .RuleFor(u => u.JobSchedules, _ => jobSchedules ?? []) diff --git a/InvoiceReminder.UnitTests.ExternalServices/SendMessage/SendMessageServiceTests.cs b/InvoiceReminder.UnitTests.ExternalServices/SendMessage/SendMessageServiceTests.cs index 07f8e80..2b35eda 100644 --- a/InvoiceReminder.UnitTests.ExternalServices/SendMessage/SendMessageServiceTests.cs +++ b/InvoiceReminder.UnitTests.ExternalServices/SendMessage/SendMessageServiceTests.cs @@ -93,7 +93,6 @@ public SendMessageServiceTests() .RuleFor(u => u.Id, faker => faker.Random.Guid()) .RuleFor(u => u.Name, faker => faker.Person.FullName) .RuleFor(u => u.Email, faker => faker.Internet.Email()) - .RuleFor(u => u.Password, faker => faker.Internet.Password()) .RuleFor(u => u.TelegramChatId, faker => faker.Random.Long(1)) .RuleFor(u => u.Invoices, []) .RuleFor(u => u.JobSchedules, []) diff --git a/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs b/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs index 0b72b07..9574c04 100644 --- a/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs +++ b/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs @@ -1,4 +1,4 @@ -using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Authentication.Abstractions; using InvoiceReminder.Authentication.Interfaces; using InvoiceReminder.Authentication.Jwt; using Microsoft.Extensions.Options; @@ -68,7 +68,7 @@ public void JwtProvider_Generate_ValidUser_ShouldReturnToken() { // Arrange var jwtProvider = new JwtProvider(_jwtOptions); - var user = new UserViewModel + var user = new UserClaims { Id = Guid.NewGuid(), Email = "user@test.com" diff --git a/InvoiceReminder.UnitTests.Infrastructure/Authentication/StringHashExtensionTests.cs b/InvoiceReminder.UnitTests.Infrastructure/Authentication/StringHashExtensionTests.cs index 02a79ab..aaaf57e 100644 --- a/InvoiceReminder.UnitTests.Infrastructure/Authentication/StringHashExtensionTests.cs +++ b/InvoiceReminder.UnitTests.Infrastructure/Authentication/StringHashExtensionTests.cs @@ -19,6 +19,8 @@ public StringHashExtensionTests() _expected512Hash = "69DFD91314578F7F329939A7EA6BE4497E6FE3909B9C8F308FE711D29D4340D90D77B7FDF359B7D0DBEED940665274F7CA514CD067895FDF59DE0CF142B62336"; } + #region ToSHA256 Tests + [TestMethod] public void ToSHA256_ValidInput_ShouldReturnExpectedHash() { @@ -29,6 +31,10 @@ public void ToSHA256_ValidInput_ShouldReturnExpectedHash() resultHash.ShouldBeEquivalentTo(_expected256Hash); } + #endregion + + #region ToSHA512 Tests + [TestMethod] public void ToSHA512_ValidInput_ShouldReturnExpectedHash() { @@ -39,6 +45,10 @@ public void ToSHA512_ValidInput_ShouldReturnExpectedHash() resultHash.ShouldBeEquivalentTo(_expected512Hash); } + #endregion + + #region ToMD5 Tests + [TestMethod] public void ToMD5_ValidInput_ShouldReturnExpectedHash() { @@ -48,4 +58,337 @@ public void ToMD5_ValidInput_ShouldReturnExpectedHash() // Assert resultHash.ShouldBeEquivalentTo(_expectedMD5Hash); } + + #endregion + + #region HashPassword Tests + + [TestMethod] + public void HashPassword_ShouldReturnNonEmptyHashAndSalt() + { + // Arrange + var password = "MySecurePassword123!"; + + // Act + var (hash, salt) = password.HashPassword(); + + // Assert + hash.ShouldSatisfyAllConditions(() => + { + hash.ShouldNotBeNullOrEmpty(); + _ = hash.ShouldBeOfType(); + }); + + salt.ShouldSatisfyAllConditions(() => + { + salt.ShouldNotBeNullOrEmpty(); + _ = salt.ShouldBeOfType(); + }); + } + + [TestMethod] + public void HashPassword_ShouldReturnValidBase64EncodedValues() + { + // Arrange + var testCases = new[] { "Password1!", "P@ssw0rd", "複雑なパスワード" }; + + foreach (var password in testCases) + { + // Act + var (hash, salt) = password.HashPassword(); + + // Assert + Should.NotThrow(() => + { + _ = Convert.FromBase64String(hash); + _ = Convert.FromBase64String(salt); + }); + } + } + + [TestMethod] + public void HashPassword_SamePasswordMultipleTimes_ShouldProduceDifferentHashesDueToRandomSalt() + { + // Arrange + var password = "MySecurePassword123!"; + var hashes = new List(); + var salts = new List(); + + // Act + for (var i = 0; i < 5; i++) + { + var (hash, salt) = password.HashPassword(); + hashes.Add(hash); + salts.Add(salt); + } + + // Assert + hashes.Distinct().Count().ShouldBe(5); + salts.Distinct().Count().ShouldBe(5); + } + + [TestMethod] + public void HashPassword_DifferentPasswords_ShouldProduceDifferentHashes() + { + // Arrange + var passwords = new[] { "Password1!", "Password2@", "Password3#" }; + + // Act + var results = passwords.Select(p => p.HashPassword()).ToList(); + + // Assert + var hashes = results.Select(r => r.Hash).Distinct().Count(); + hashes.ShouldBe(results.Count); + } + + [TestMethod] + [DataRow("a")] + [DataRow("P@ssw0rd!#$%&*()[]{}")] + [DataRow("複雑なパスワード")] + [DataRow("x")] + public void HashPassword_WithValidInputs_ShouldProduceValidHash(string password) + { + // Act + var (hash, salt) = password.HashPassword(); + + // Assert + hash.ShouldNotBeNullOrEmpty(); + salt.ShouldNotBeNullOrEmpty(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("\t")] + [DataRow("\n")] + [DataRow("\r")] + [DataRow(" \t\n\r ")] + public void HashPassword_WithNullOrEmptyOrWhitespaceInput_ShouldThrowArgumentException(string password) + { + // Act & Assert + var exception = Should.Throw(() => password.HashPassword()); + + exception.ParamName.ShouldBe("inputString"); + } + + #endregion + + #region VerifyPassword Tests + + [TestMethod] + public void VerifyPassword_CorrectPassword_ShouldReturnTrue() + { + // Arrange + var password = "MySecurePassword123!"; + var (hash, salt) = password.HashPassword(); + + // Act + var result = password.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeTrue(); + } + + [TestMethod] + public void VerifyPassword_IncorrectPassword_ShouldReturnFalse() + { + // Arrange + var password = "MySecurePassword123!"; + var wrongPassword = "WrongPassword456@"; + var (hash, salt) = password.HashPassword(); + + // Act + var result = wrongPassword.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + [DataRow("MySecurePassword123!", "MySecurePassword123!", true)] + [DataRow("MySecurePassword123!", "WrongPassword456@", false)] + [DataRow("MySecurePassword123!", "mysecurepassword123!", false)] + [DataRow("P@ssw0rd!#$%&*()[]{}", "P@ssw0rd!#$%&*()[]{}", true)] + [DataRow("Pässwörd123!", "Pässwörd123!", true)] + [DataRow("abc123", "ABC123", false)] + public void VerifyPassword_VariousScenarios_ShouldReturnExpectedResult(string originalPassword, string verifyPassword, bool expectedResult) + { + // Arrange + var (hash, salt) = originalPassword.HashPassword(); + + // Act + var result = verifyPassword.VerifyPassword(hash, salt); + + // Assert + result.ShouldBe(expectedResult); + } + + [TestMethod] + public void VerifyPassword_ModifiedHash_ShouldReturnFalse() + { + // Arrange + var password = "MySecurePassword123!"; + var (hash, salt) = password.HashPassword(); + + // Modify hash by replacing a character in the middle with a different valid Base64 character + var modifiedHash = hash[..^2] + "AB"; + + // Act + var result = password.VerifyPassword(modifiedHash, salt); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void VerifyPassword_ModifiedSaltWithValidBase64_ShouldReturnFalse() + { + // Arrange + var password = "MySecurePassword123!"; + var (hash, salt) = password.HashPassword(); + + // Modify salt by replacing the last character with another valid Base64 character + var modifiedSalt = salt[..^2] + "AB"; + + // Act + var result = password.VerifyPassword(hash, modifiedSalt); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void VerifyPassword_ModifiedSaltWithInvalidBase64_ShouldThrowFormatException() + { + // Arrange + var password = "MySecurePassword123!"; + var (hash, salt) = password.HashPassword(); + + // Modify salt with an invalid Base64 character + var invalidSalt = salt[..^1] + "!"; + + // Act & Assert + _ = Should.Throw(() => password.VerifyPassword(hash, invalidSalt)); + } + + [TestMethod] + public void VerifyPassword_CorrectPasswordMultipleTimes_ShouldAlwaysReturnTrue() + { + // Arrange + var password = "MySecurePassword123!"; + var (hash, salt) = password.HashPassword(); + + // Act & Assert + for (var i = 0; i < 5; i++) + { + var result = password.VerifyPassword(hash, salt); + result.ShouldBeTrue(); + } + } + + [TestMethod] + public void VerifyPassword_IncorrectPasswordMultipleTimes_ShouldAlwaysReturnFalse() + { + // Arrange + var password = "MySecurePassword123!"; + var wrongPassword = "WrongPassword456@"; + var (hash, salt) = password.HashPassword(); + + // Act & Assert + for (var i = 0; i < 5; i++) + { + var result = wrongPassword.VerifyPassword(hash, salt); + result.ShouldBeFalse(); + } + } + + [TestMethod] + public void VerifyPassword_WhitespaceAffectingPassword_ShouldReturnFalse() + { + // Arrange + var testCases = new Dictionary + { + { "My Password 123!", "MyPassword123!" }, // Missing spaces + { "MyPassword123!", "My Password 123!" }, // Added spaces + { "MyPassword123", "MyPassword123 " } // Trailing space + }; + + foreach (var kvp in testCases) + { + var originalPassword = kvp.Key; + var modifiedPassword = kvp.Value; + var (hash, salt) = originalPassword.HashPassword(); + + // Act + var result = modifiedPassword.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeFalse($"Password '{modifiedPassword}' should not match hash of '{originalPassword}'"); + } + } + + [TestMethod] + public void VerifyPassword_LongPassword_ShouldVerifyCorrectly() + { + // Arrange + var password = new string('x', 1000); + var (hash, salt) = password.HashPassword(); + + // Act + var result = password.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeTrue(); + } + + [TestMethod] + public void VerifyPassword_SingleCharacterPassword_ShouldVerifyCorrectly() + { + // Arrange + var password = "a"; + var (hash, salt) = password.HashPassword(); + + // Act + var result = password.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeTrue(); + } + + [TestMethod] + public void VerifyPassword_SpecialCharactersPassword_ShouldVerifyCorrectly() + { + // Arrange + var password = "!@#$%^&*()_+-=[]{}|;:,.<>?"; + var (hash, salt) = password.HashPassword(); + + // Act + var result = password.VerifyPassword(hash, salt); + + // Assert + result.ShouldBeTrue(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("\t")] + [DataRow("\n")] + [DataRow("\r")] + [DataRow(" \t\n\r ")] + public void VerifyPassword_WithNullOrEmptyOrWhitespaceInput_ShouldThrowArgumentException(string password) + { + // Arrange + var validPassword = "ValidPassword123!"; + var (hash, salt) = validPassword.HashPassword(); + + // Act & Assert + var exception = Should.Throw(() => password.VerifyPassword(hash, salt)); + + exception.ParamName.ShouldBe("inputString"); + } + + #endregion } diff --git a/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserConfigTests.cs b/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserConfigTests.cs index 2d5fbf6..5548f33 100644 --- a/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserConfigTests.cs +++ b/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserConfigTests.cs @@ -38,7 +38,7 @@ public void UserConfig_ShouldConfigureEntityCorrectly() .FirstOrDefault(i => i.Properties.Count == 1 && i.Properties[0].Name == nameof(User.Email)); _ = emailIndex.ShouldNotBeNull(); emailIndex.IsUnique.ShouldBeTrue(); - emailIndex.GetDatabaseName().ShouldBe("idx_user_email"); + emailIndex.GetDatabaseName().ShouldBe("IX_user_email"); // Verifica propriedade Id var idProperty = entityType.FindProperty(nameof(User.Id)); @@ -70,13 +70,6 @@ public void UserConfig_ShouldConfigureEntityCorrectly() emailProperty.GetMaxLength().ShouldBe(255); emailProperty.IsNullable.ShouldBeFalse(); - // Verifica propriedade Password - var passwordProperty = entityType.FindProperty(nameof(User.Password)); - _ = passwordProperty.ShouldNotBeNull(); - passwordProperty.GetColumnName().ShouldBe("password"); - passwordProperty.GetMaxLength().ShouldBe(255); - passwordProperty.IsNullable.ShouldBeFalse(); - // Verifica propriedade CreatedAt (herdada de EntityDefaults) var createdAtProperty = entityType.FindProperty(nameof(User.CreatedAt)); _ = createdAtProperty.ShouldNotBeNull(); diff --git a/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserPasswordConfigTests.cs b/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserPasswordConfigTests.cs new file mode 100644 index 0000000..79d2244 --- /dev/null +++ b/InvoiceReminder.UnitTests.Infrastructure/Data/EntitiesConfig/UserPasswordConfigTests.cs @@ -0,0 +1,79 @@ +using InvoiceReminder.Data.Persistence.EntitiesConfig; +using InvoiceReminder.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Shouldly; + +namespace InvoiceReminder.UnitTests.Infrastructure.Data.EntitiesConfig; + +[TestClass] +public sealed class UserPasswordConfigTests +{ + [TestMethod] + public void UserPasswordConfig_ShouldConfigureEntityCorrectly() + { + // Arrange + var builder = new ModelBuilder(new ConventionSet()); + var config = new UserPasswordConfig(); + + // Act + config.Configure(builder.Entity()); + + // Assert + var entityType = builder.Model.FindEntityType(typeof(UserPassword)); + _ = entityType.ShouldNotBeNull(); + + // Verifica tabela + entityType.GetTableName().ShouldBe("user_password"); + + // Verifica chave primária + var primaryKey = entityType.FindPrimaryKey(); + _ = primaryKey.ShouldNotBeNull(); + primaryKey.Properties.Count.ShouldBe(1); + primaryKey.Properties[0].Name.ShouldBe(nameof(UserPassword.Id)); + + // Verifica propriedade Id + var idProperty = entityType.FindProperty(nameof(UserPassword.Id)); + _ = idProperty.ShouldNotBeNull(); + idProperty.GetColumnName().ShouldBe("id"); + idProperty.GetColumnType().ShouldBe("uuid"); + idProperty.IsNullable.ShouldBeFalse(); + idProperty.ValueGenerated.ShouldBe(ValueGenerated.OnAdd); + + // Verifica propriedade UserId + var userIdProperty = entityType.FindProperty(nameof(UserPassword.UserId)); + _ = userIdProperty.ShouldNotBeNull(); + userIdProperty.GetColumnName().ShouldBe("user_id"); + userIdProperty.GetColumnType().ShouldBe("uuid"); + userIdProperty.IsNullable.ShouldBeFalse(); + + // Verifica propriedade PasswordHash + var passwordHashProperty = entityType.FindProperty(nameof(UserPassword.PasswordHash)); + _ = passwordHashProperty.ShouldNotBeNull(); + passwordHashProperty.GetColumnName().ShouldBe("password_hash"); + passwordHashProperty.GetMaxLength().ShouldBe(512); + passwordHashProperty.IsNullable.ShouldBeFalse(); + + // Verifica propriedade PasswordSalt + var passwordSaltProperty = entityType.FindProperty(nameof(UserPassword.PasswordSalt)); + _ = passwordSaltProperty.ShouldNotBeNull(); + passwordSaltProperty.GetColumnName().ShouldBe("password_salt"); + passwordSaltProperty.GetMaxLength().ShouldBe(256); + passwordSaltProperty.IsNullable.ShouldBeFalse(); + + // Verifica propriedade CreatedAt (herdada de EntityDefaults) + var createdAtProperty = entityType.FindProperty(nameof(UserPassword.CreatedAt)); + _ = createdAtProperty.ShouldNotBeNull(); + createdAtProperty.GetColumnName().ShouldBe("created_at"); + createdAtProperty.GetColumnType().ShouldBe("timestamp with time zone"); + createdAtProperty.IsNullable.ShouldBeFalse(); + + // Verifica propriedade UpdatedAt (herdada de EntityDefaults) + var updatedAtProperty = entityType.FindProperty(nameof(UserPassword.UpdatedAt)); + _ = updatedAtProperty.ShouldNotBeNull(); + updatedAtProperty.GetColumnName().ShouldBe("updated_at"); + updatedAtProperty.GetColumnType().ShouldBe("timestamp with time zone"); + updatedAtProperty.IsNullable.ShouldBeFalse(); + } +} diff --git a/global.json b/global.json index 36b0c0a..2cf953f 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,8 @@ { "sdk": { - "version": "10.0.100" + "version": "10.0.102" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} From fdb757ed277c03eb537ffe1e820d7e4deb96a1de Mon Sep 17 00:00:00 2001 From: "Jefferson L. da Silva" Date: Mon, 19 Jan 2026 19:06:44 -0300 Subject: [PATCH 2/4] Refactors and enhances user authentication Improves user authentication by implementing password hashing and validation. Adds email validation to the login request and enhances the JWT token generation process. Also refactors endpoint definitions. --- .../AuthenticationSetup/LoginRequest.cs | 6 ++++ .../Endpoints/ScanEmailDefinitionEndpoints.cs | 2 +- .../AppServices/UserAppService.cs | 12 ++++---- .../AppServices/UserPasswordAppService.cs | 14 ++++++++-- .../ViewModels/InvoiceViewModel.cs | 8 +++--- .../ViewModels/UserPasswordViewModel.cs | 2 +- .../ViewModels/UserViewModel.cs | 2 +- .../Abstractions/UserClaims.cs | 4 +-- .../Extensions/StringHashExtension.cs | 4 +-- .../Jwt/JwtProvider.cs | 14 ++++++++-- .../20260109013522_Initial_Create.cs | 2 ++ .../EntitiesConfig/UserPasswordConfig.cs | 9 ++++++ .../Endpoints/GoogleOAuthEndpointsTests.cs | 19 ++++++++----- .../Endpoints/InvoiceEndpointsTests.cs | 19 ++++++++----- .../Endpoints/JobScheduleEndPointsTests.cs | 19 ++++++++----- .../Endpoints/LoginEndpointTests.cs | 23 +++++++++------ .../ScanEmailDefinitionEndpointsTests.cs | 28 +++++++++++-------- .../Endpoints/SendMessageEndpointsTests.cs | 19 ++++++++----- .../Endpoints/UserEndpointsTests.cs | 19 ++++++++----- .../Endpoints/UserPasswordEndpointsTests.cs | 19 ++++++++----- .../UserPasswordAppServiceTests.cs | 10 +++---- .../Authentication/JwtProviderTests.cs | 2 +- 22 files changed, 169 insertions(+), 87 deletions(-) diff --git a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs index 0aafa05..e91a1a2 100644 --- a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs +++ b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs @@ -1,7 +1,13 @@ +using System.ComponentModel.DataAnnotations; + namespace InvoiceReminder.API.AuthenticationSetup; public record LoginRequest { + [Required] + [EmailAddress] public string Email { get; init; } + + [Required] public string Password { get; init; } } diff --git a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs index 7b917ac..6986288 100644 --- a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs +++ b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs @@ -78,7 +78,7 @@ private static void MapGetByUserId(RouteGroupBuilder endpoint) private static void MapGetBySenderEmailAddress(RouteGroupBuilder endpoint) { - _ = endpoint.MapGet("/{email}/{id}", + _ = endpoint.MapGet("/getby-sender/{email}/{id}", async (IScanEmailDefinitionAppService appService, string email, Guid id, CancellationToken ct) => { var result = await appService.GetBySenderEmailAddressAsync(email, id, ct); diff --git a/InvoiceReminder.Application/AppServices/UserAppService.cs b/InvoiceReminder.Application/AppServices/UserAppService.cs index 030288f..7e7b472 100644 --- a/InvoiceReminder.Application/AppServices/UserAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserAppService.cs @@ -28,8 +28,7 @@ public override async Task> AddAsync( return Result.Failure($"Parameter {nameof(viewModel)} was Null."); } - var tempPassword = DateTime.Now.ToString("ddMMyyyy"); - (var pHash, var pSalt) = tempPassword.HashPassword(); + (var pHash, var pSalt) = viewModel.UserPassword.PasswordHash.HashPassword(); viewModel.UserPassword.UserId = viewModel.Id; viewModel.UserPassword.PasswordHash = pHash; @@ -43,7 +42,9 @@ public override async Task> AddAsync( return Result.Success(entity.Adapt()); } - public async Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default) + public async Task> GetByEmailAsync( + string value, + CancellationToken cancellationToken = default) { var entity = await _repository.GetByEmailAsync(value, cancellationToken); @@ -52,8 +53,9 @@ public async Task> GetByEmailAsync(string value, Cancellat : Result.Success(entity.Adapt()); } - public async Task> ValidateUserPasswordAsync(string email, string password, - CancellationToken cancellationToken = default) + public async Task> ValidateUserPasswordAsync( + string email, string password, + CancellationToken cancellationToken = default) { var entity = await _repository.GetByEmailAsync(email, cancellationToken); var isValid = entity is not null && diff --git a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs index e68c55b..8842e31 100644 --- a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs @@ -29,6 +29,11 @@ public override async Task> AddAsync( return Result.Failure("The provided obejct data was Null."); } + if (string.IsNullOrWhiteSpace(viewModel.PasswordHash)) + { + return Result.Failure("Password is required."); + } + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); viewModel.PasswordHash = pHash; @@ -48,11 +53,16 @@ public override async Task> BulkInsertAsync( { if (viewModelCollection is null or { Count: 0 }) { - return Result.Failure("The provided obejct data was Null or Empty."); + return Result.Failure("The provided object data was Null or Empty."); } foreach (var viewModel in viewModelCollection) { + if (string.IsNullOrWhiteSpace(viewModel.PasswordHash)) + { + return Result.Failure("Password is required."); + } + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); viewModel.PasswordHash = pHash; @@ -82,7 +92,7 @@ public override async Task> UpdateAsync( { if (viewModel is null) { - return Result.Failure("The provided obejct data was Null."); + return Result.Failure("The provided object data was Null."); } (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); diff --git a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs index bba7fdf..57ce860 100644 --- a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs @@ -13,19 +13,19 @@ public class InvoiceViewModel : ViewModelDefaults [JsonPropertyOrder(3)] public string Bank { get; set; } - [JsonPropertyOrder(3)] + [JsonPropertyOrder(4)] public string Beneficiary { get; set; } [Required] - [JsonPropertyOrder(4)] + [JsonPropertyOrder(5)] public decimal Amount { get; set; } [Required] - [JsonPropertyOrder(5)] + [JsonPropertyOrder(6)] public string Barcode { get; set; } [Required] - [JsonPropertyOrder(6)] + [JsonPropertyOrder(7)] public DateTime DueDate { get; set; } public InvoiceViewModel() diff --git a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs index 16df85b..84f003d 100644 --- a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs @@ -7,7 +7,7 @@ public class UserPasswordViewModel : ViewModelDefaults [JsonPropertyOrder(2)] public Guid UserId { get; set; } - [JsonIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public string PasswordHash { get; set; } [JsonIgnore] diff --git a/InvoiceReminder.Application/ViewModels/UserViewModel.cs b/InvoiceReminder.Application/ViewModels/UserViewModel.cs index 742b697..6f3abce 100644 --- a/InvoiceReminder.Application/ViewModels/UserViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/UserViewModel.cs @@ -17,7 +17,7 @@ public class UserViewModel : ViewModelDefaults [JsonPropertyOrder(4)] public string Email { get; set; } - [JsonIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public virtual UserPasswordViewModel UserPassword { get; set; } [JsonPropertyOrder(5)] diff --git a/InvoiceReminder.Authentication/Abstractions/UserClaims.cs b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs index 2ede79b..7824f0f 100644 --- a/InvoiceReminder.Authentication/Abstractions/UserClaims.cs +++ b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs @@ -2,6 +2,6 @@ namespace InvoiceReminder.Authentication.Abstractions; public record UserClaims { - public Guid Id { get; init; } - public string Email { get; init; } + public required Guid Id { get; init; } + public required string Email { get; init; } } diff --git a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs index 8535bde..f3d3b49 100644 --- a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs +++ b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs @@ -13,7 +13,7 @@ public static (string Hash, string Salt) HashPassword(this string inputString) var salt = RandomNumberGenerator.GetBytes(16); - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) { Salt = salt, DegreeOfParallelism = 8, @@ -34,7 +34,7 @@ public static bool VerifyPassword(this string inputString, string storedHash, st var salt = Convert.FromBase64String(storedSalt); - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) { Salt = salt, DegreeOfParallelism = 8, diff --git a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs index 62c20a6..8c57650 100644 --- a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs +++ b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs @@ -24,10 +24,20 @@ public JwtObject Generate(UserClaims user) { ArgumentNullException.ThrowIfNull(user); + if (user.Id == Guid.Empty) + { + throw new ArgumentException("User Id cannot be empty.", nameof(user)); + } + + if (string.IsNullOrWhiteSpace(user.Email)) + { + throw new ArgumentException("User Email cannot be null or empty.", nameof(user)); + } + var claims = new Claim[] { - new (JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new (JwtRegisteredClaimNames.Email, user.Email) + new (JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new (JwtRegisteredClaimNames.Email, user.Email) }; var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey)); diff --git a/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs index 2d5bc94..1f79ac0 100644 --- a/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs +++ b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; #nullable disable +#pragma warning disable S1192 namespace InvoiceReminder.Data.Migrations; @@ -273,3 +274,4 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "invoice_reminder"); } } +#pragma warning restore S1192 diff --git a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs index 5f9f4de..3e06a13 100644 --- a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs +++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs @@ -15,6 +15,9 @@ public void Configure(EntityTypeBuilder builder) _ = builder.HasKey(x => x.Id); + _ = builder.HasIndex(x => x.UserId) + .IsUnique(); + _ = builder.Property(x => x.Id) .HasColumnName("id") .HasColumnType("uuid") @@ -45,5 +48,11 @@ public void Configure(EntityTypeBuilder builder) .HasColumnName("updated_at") .HasColumnType("timestamp with time zone") .IsRequired(); + + _ = builder.HasOne() + .WithOne(x => x.UserPassword) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/GoogleOAuthEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/GoogleOAuthEndpointsTests.cs index 01ae8e4..b8f762b 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/GoogleOAuthEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/GoogleOAuthEndpointsTests.cs @@ -18,9 +18,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class GoogleOAuthEndpointsTests +public sealed class GoogleOAuthEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IGoogleOAuthService _oAuthService; private const string basepath = "/api/google_oauth"; @@ -29,12 +30,10 @@ public sealed class GoogleOAuthEndpointsTests public GoogleOAuthEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _oAuthService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _oAuthService = _factory.Services.GetRequiredService(); } #region GetAuthUrl Tests @@ -511,4 +510,10 @@ public async Task Revoke_WhenServiceReturnsFailure_ShouldReturnProblem() } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/InvoiceEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/InvoiceEndpointsTests.cs index 23dcbd5..5370bb6 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/InvoiceEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/InvoiceEndpointsTests.cs @@ -18,9 +18,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class InvoiceEndpointsTests +public sealed class InvoiceEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IInvoiceAppService _invoiceAppService; private readonly Faker _invoiceViewModelFaker; @@ -31,12 +32,10 @@ public sealed class InvoiceEndpointsTests public InvoiceEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _invoiceAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _invoiceAppService = _factory.Services.GetRequiredService(); _faker = new Faker(); _invoiceViewModelFaker = new Faker() @@ -632,4 +631,10 @@ public async Task DeleteInvoice_WhenUserIsAuthenticatedButServiceFails_ShouldRet _ = result.ShouldBeOfType(); } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/JobScheduleEndPointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/JobScheduleEndPointsTests.cs index 3529a2b..c2990d7 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/JobScheduleEndPointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/JobScheduleEndPointsTests.cs @@ -18,9 +18,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class JobScheduleEndPointsTests +public sealed class JobScheduleEndPointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IJobScheduleAppService _jobScheduleAppService; private readonly Faker _jobScheduleViewModelFaker; @@ -30,12 +31,10 @@ public sealed class JobScheduleEndPointsTests public JobScheduleEndPointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _jobScheduleAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _jobScheduleAppService = _factory.Services.GetRequiredService(); _jobScheduleViewModelFaker = new Faker() .RuleFor(j => j.Id, faker => faker.Random.Guid()) @@ -632,4 +631,10 @@ public async Task DeleteJobSchedule_WhenUserIsAuthenticatedButServiceFails_Shoul _ = result.ShouldBeOfType(); } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs index 99cc735..df7a403 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/LoginEndpointTests.cs @@ -19,9 +19,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class LoginEndpointTests +public sealed class LoginEndpointTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IJwtProvider _jwtProvider; private readonly IUserAppService _userAppService; private readonly Faker _userViewModelFaker; @@ -33,12 +34,10 @@ public sealed class LoginEndpointTests public LoginEndpointTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _jwtProvider = serviceProvider.GetRequiredService(); - _userAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _jwtProvider = _factory.Services.GetRequiredService(); + _userAppService = _factory.Services.GetRequiredService(); _faker = new Faker(); _userViewModelFaker = new Faker() @@ -202,7 +201,9 @@ public async Task Login_WithEmptyEmailOrPassword_ShouldReturnBadRequest() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.DidNotReceive().GetByEmailAsync(Arg.Any(), Arg.Any()); + _ = _userAppService.DidNotReceive() + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); @@ -245,4 +246,10 @@ public async Task Login_WhenServiceFails_ShouldReturnInternalServerError() _ = result.ShouldBeOfType(); }); } + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/ScanEmailDefinitionEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/ScanEmailDefinitionEndpointsTests.cs index 231e151..82ad120 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/ScanEmailDefinitionEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/ScanEmailDefinitionEndpointsTests.cs @@ -18,9 +18,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class ScanEmailDefinitionEndpointsTests +public sealed class ScanEmailDefinitionEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IScanEmailDefinitionAppService _scanEmailDefinitionAppService; private readonly Faker _scanEmailDefinitionViewModelFaker; @@ -31,12 +32,10 @@ public sealed class ScanEmailDefinitionEndpointsTests public ScanEmailDefinitionEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _scanEmailDefinitionAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _scanEmailDefinitionAppService = _factory.Services.GetRequiredService(); _faker = new Faker(); _scanEmailDefinitionViewModelFaker = new Faker() @@ -398,7 +397,7 @@ public async Task GetScanEmailDefinitionBySenderEmailAddressAndUserId_WhenUserIs // Arrange var id = Guid.NewGuid(); var email = _faker.Internet.Email(); - var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/{email}/{id}"); + var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/getby-sender/{email}/{id}"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); var expectedResult = Result.Success( @@ -430,7 +429,8 @@ public async Task GetScanEmailDefinitionBySenderEmailAddressAndUserId_WhenUserIs public async Task GetScanEmailDefinitionBySenderEmailAddressAndUserId_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized() { // Arrange - var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/{Guid.NewGuid()}/{Guid.NewGuid()}"); + var email = _faker.Internet.Email; + var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/getby-sender/{email}/{Guid.NewGuid()}"); _ = _authorizationService.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any>()) @@ -449,7 +449,7 @@ public async Task GetScanEmailDefinitionBySenderEmailAddressAndUserId_WhenUserIs // Arrange var id = Guid.NewGuid(); var email = _faker.Internet.Email(); - var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/{email}/{id}"); + var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/getby-sender/{email}/{id}"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); _ = _scanEmailDefinitionAppService @@ -479,7 +479,7 @@ public async Task GetScanEmailDefinitionBySenderEmailAddressAndUserId_WhenUserIs // Arrange var id = Guid.NewGuid(); var email = _faker.Internet.Email(); - var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/{email}/{id}"); + var request = new HttpRequestMessage(HttpMethod.Get, $"{basepath}/getby-sender/{email}/{id}"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test_token"); var expectedResult = Result. @@ -752,4 +752,10 @@ public async Task DeleteScanEmailDefinition_WhenUserIsAuthenticatedButServiceFai _ = result.ShouldBeOfType(); } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/SendMessageEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/SendMessageEndpointsTests.cs index 524b8cd..62fee02 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/SendMessageEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/SendMessageEndpointsTests.cs @@ -14,9 +14,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class SendMessageEndpointsTests +public sealed class SendMessageEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly ISendMessageService _sendMessageService; private const string basepath = "/api/send_message"; @@ -25,12 +26,10 @@ public sealed class SendMessageEndpointsTests public SendMessageEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _sendMessageService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _sendMessageService = _factory.Services.GetRequiredService(); } #region MapGet Tests @@ -278,4 +277,10 @@ public async Task SendMessage_WhenServiceReturnsWarningMessage_ShouldReturnOkWit } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs index b0e8a7e..93e2a3c 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/UserEndpointsTests.cs @@ -18,9 +18,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class UserEndpointsTests +public sealed class UserEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IUserAppService _userAppService; private readonly Faker _userViewModelFaker; @@ -31,12 +32,10 @@ public sealed class UserEndpointsTests public UserEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _userAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _userAppService = _factory.Services.GetRequiredService(); _faker = new Faker(); _userViewModelFaker = new Faker() @@ -697,4 +696,10 @@ public async Task DeleteUser_WhenUserIsAuthenticatedButServiceFails_ShouldReturn _ = result.ShouldBeOfType(); } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs b/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs index 10522c8..1ec62b7 100644 --- a/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs +++ b/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs @@ -17,9 +17,10 @@ namespace InvoiceReminder.UnitTests.API.Endpoints; [TestClass] -public sealed class UserPasswordEndpointsTests +public sealed class UserPasswordEndpointsTests : IDisposable { private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; private readonly IAuthorizationService _authorizationService; private readonly IUserPasswordAppService _userPasswordAppService; private readonly Faker _userPasswordViewModelFaker; @@ -29,12 +30,10 @@ public sealed class UserPasswordEndpointsTests public UserPasswordEndpointsTests() { - var factory = new CustomWebApplicationFactory(); - var serviceProvider = factory.Services; - - _client = factory.CreateClient(); - _authorizationService = serviceProvider.GetRequiredService(); - _userPasswordAppService = serviceProvider.GetRequiredService(); + _factory = new CustomWebApplicationFactory(); + _client = _factory.CreateClient(); + _authorizationService = _factory.Services.GetRequiredService(); + _userPasswordAppService = _factory.Services.GetRequiredService(); _userPasswordViewModelFaker = new Faker() .RuleFor(u => u.Id, faker => faker.Random.Guid()) @@ -368,4 +367,10 @@ public async Task DeleteUserPassword_WhenUserIsAuthenticatedButServiceFails_Shou } #endregion + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } } diff --git a/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs index a0343f4..ecb4f8c 100644 --- a/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs @@ -74,7 +74,7 @@ public async Task AddAsync_Should_Hash_Password_Before_Adding() var plainPassword = viewModel.PasswordHash; _ = _repository.AddAsync(Arg.Any(), Arg.Any()) - .Returns(viewModel.Adapt()); + .Returns(x => x.Arg()); _ = _unitOfWork.SaveChangesAsync(Arg.Any()) .Returns(Task.CompletedTask); @@ -176,7 +176,7 @@ public async Task BulkInsertAsync_Should_Hash_All_Passwords_Before_Inserting() result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBe(3); // All original passwords should have been hashed (changed) - viewModels.All(v => plainPasswords.Contains(v.PasswordHash)).ShouldBeFalse(); + viewModels.Any(v => plainPasswords.Contains(v.PasswordHash)).ShouldBeFalse(); }); } @@ -194,7 +194,7 @@ public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null { result.IsSuccess.ShouldBeFalse(); result.Value.ShouldBe(0); - result.Error.ShouldBe("The provided obejct data was Null or Empty."); + result.Error.ShouldBe("The provided object data was Null or Empty."); }); } @@ -212,7 +212,7 @@ public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Empt { result.IsSuccess.ShouldBeFalse(); result.Value.ShouldBe(0); - result.Error.ShouldBe("The provided obejct data was Null or Empty."); + result.Error.ShouldBe("The provided object data was Null or Empty."); }); } @@ -265,7 +265,7 @@ public async Task UpdateAsync_Should_Return_Failure_When_ViewModel_Is_Null() { result.IsSuccess.ShouldBeFalse(); result.Value.ShouldBeNull(); - result.Error.ShouldBe("The provided obejct data was Null."); + result.Error.ShouldBe("The provided object data was Null."); }); } diff --git a/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs b/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs index 9574c04..de68f6c 100644 --- a/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs +++ b/InvoiceReminder.UnitTests.Infrastructure/Authentication/JwtProviderTests.cs @@ -88,7 +88,7 @@ public void JwtProvider_Generate_ValidUser_ShouldReturnToken() } [TestMethod] - public void JwtProvider_Generate_ShouldThrowArgumentNullExceptionWhenUserViewModelIsNull() + public void JwtProvider_Generate_ShouldThrowArgumentNullExceptionWhenUserClaimsIsNull() { // Arrange var jwtProvider = new JwtProvider(_jwtOptions); From 4b7e449b86858223dc48cacab77f7cbd7272caa5 Mon Sep 17 00:00:00 2001 From: "Jefferson L. da Silva" Date: Tue, 20 Jan 2026 03:02:29 -0300 Subject: [PATCH 3/4] Enhances password hashing security Improves password hashing by introducing a configurable parallelism factor for Argon2id. This change allows adjusting the computational effort required for password hashing, improving security against brute-force attacks. The parallelism factor is read from configuration, allowing for flexible adjustment based on available resources. It also adds new dependency injection for IConfigurationService This refactor introduces a helper method, GetMaxDegreeOfParallelism, that dynamically sets the degree of parallelism based on the server's processor count and the configured parallelism factor, ensuring optimal resource usage and security. --- InvoiceReminder.API/appsettings.json | 3 + .../AppServices/UserAppService.cs | 17 +++-- .../AppServices/UserPasswordAppService.cs | 11 +-- .../Extensions/StringHashExtension.cs | 13 ++-- .../Configuration/ConfigurationService.cs | 5 ++ .../Configuration/IConfigurationService.cs | 1 + .../AppServices/UserAppServiceTests.cs | 9 ++- .../UserPasswordAppServiceTests.cs | 29 ++++---- .../ConfigurationServiceTests.cs | 72 +++++++++++++++++++ 9 files changed, 133 insertions(+), 27 deletions(-) diff --git a/InvoiceReminder.API/appsettings.json b/InvoiceReminder.API/appsettings.json index 2406b57..766550e 100644 --- a/InvoiceReminder.API/appsettings.json +++ b/InvoiceReminder.API/appsettings.json @@ -22,5 +22,8 @@ "Issuer": "SECRET_ISSUER", "Audience": "SECRET_AUDIENCE", "SecretKey": "SECRET_KEY" + }, + "Security": { + "ParallelismFactor": 2 } } diff --git a/InvoiceReminder.Application/AppServices/UserAppService.cs b/InvoiceReminder.Application/AppServices/UserAppService.cs index 7e7b472..c42829e 100644 --- a/InvoiceReminder.Application/AppServices/UserAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserAppService.cs @@ -4,17 +4,21 @@ using InvoiceReminder.Data.Interfaces; using InvoiceReminder.Domain.Abstractions; using InvoiceReminder.Domain.Entities; +using InvoiceReminder.Domain.Services.Configuration; using Mapster; namespace InvoiceReminder.Application.AppServices; public sealed class UserAppService : BaseAppService, IUserAppService { + private readonly int _parallelismFactor; private readonly IUserRepository _repository; private readonly IUnitOfWork _unitOfWork; - public UserAppService(IUserRepository repository, IUnitOfWork unitOfWork) : base(repository, unitOfWork) + public UserAppService(IConfigurationService configuration, IUserRepository repository, IUnitOfWork unitOfWork) + : base(repository, unitOfWork) { + _parallelismFactor = configuration.GetValue("Security:ParallelismFactor"); _repository = repository; _unitOfWork = unitOfWork; } @@ -28,7 +32,12 @@ public override async Task> AddAsync( return Result.Failure($"Parameter {nameof(viewModel)} was Null."); } - (var pHash, var pSalt) = viewModel.UserPassword.PasswordHash.HashPassword(); + if (viewModel.UserPassword is null || string.IsNullOrWhiteSpace(viewModel.UserPassword.PasswordHash)) + { + return Result.Failure("Password is required."); + } + + (var pHash, var pSalt) = viewModel.UserPassword.PasswordHash.HashPassword(_parallelismFactor); viewModel.UserPassword.UserId = viewModel.Id; viewModel.UserPassword.PasswordHash = pHash; @@ -58,8 +67,8 @@ public async Task> ValidateUserPasswordAsync( CancellationToken cancellationToken = default) { var entity = await _repository.GetByEmailAsync(email, cancellationToken); - var isValid = entity is not null && - password.VerifyPassword(entity.UserPassword.PasswordHash, entity.UserPassword.PasswordSalt); + var isValid = entity is not null && password + .VerifyPassword(entity.UserPassword.PasswordHash, entity.UserPassword.PasswordSalt, _parallelismFactor); return !isValid ? Result.Failure("User not Found.") diff --git a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs index 8842e31..337bd2d 100644 --- a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs @@ -4,18 +4,21 @@ using InvoiceReminder.Data.Interfaces; using InvoiceReminder.Domain.Abstractions; using InvoiceReminder.Domain.Entities; +using InvoiceReminder.Domain.Services.Configuration; using Mapster; namespace InvoiceReminder.Application.AppServices; public class UserPasswordAppService : BaseAppService, IUserPasswordAppService { + private readonly int _parallelismFactor; private readonly IUserPasswordRepository _repository; private readonly IUnitOfWork _unitOfWork; - public UserPasswordAppService(IUserPasswordRepository repository, IUnitOfWork unitOfWork) + public UserPasswordAppService(IConfigurationService configuration, IUserPasswordRepository repository, IUnitOfWork unitOfWork) : base(repository, unitOfWork) { + _parallelismFactor = configuration.GetValue("Security:ParallelismFactor"); _repository = repository; _unitOfWork = unitOfWork; } @@ -34,7 +37,7 @@ public override async Task> AddAsync( return Result.Failure("Password is required."); } - (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor); viewModel.PasswordHash = pHash; viewModel.PasswordSalt = pSalt; @@ -63,7 +66,7 @@ public override async Task> BulkInsertAsync( return Result.Failure("Password is required."); } - (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor); viewModel.PasswordHash = pHash; viewModel.PasswordSalt = pSalt; @@ -95,7 +98,7 @@ public override async Task> UpdateAsync( return Result.Failure("The provided object data was Null."); } - (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(); + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor); viewModel.PasswordHash = pHash; viewModel.PasswordSalt = pSalt; diff --git a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs index f3d3b49..22ee393 100644 --- a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs +++ b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs @@ -7,7 +7,7 @@ namespace InvoiceReminder.Authentication.Extensions; public static class StringHashExtension { - public static (string Hash, string Salt) HashPassword(this string inputString) + public static (string Hash, string Salt) HashPassword(this string inputString, int parallelismFactor = 2) { ArgumentException.ThrowIfNullOrWhiteSpace(inputString); @@ -16,7 +16,7 @@ public static (string Hash, string Salt) HashPassword(this string inputString) using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) { Salt = salt, - DegreeOfParallelism = 8, + DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor), Iterations = 4, MemorySize = 1024 * 64 }; @@ -28,7 +28,7 @@ public static (string Hash, string Salt) HashPassword(this string inputString) return (hash, saltBase64); } - public static bool VerifyPassword(this string inputString, string storedHash, string storedSalt) + public static bool VerifyPassword(this string inputString, string storedHash, string storedSalt, int parallelismFactor = 2) { ArgumentException.ThrowIfNullOrWhiteSpace(inputString); @@ -37,7 +37,7 @@ public static bool VerifyPassword(this string inputString, string storedHash, st using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) { Salt = salt, - DegreeOfParallelism = 8, + DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor), Iterations = 4, MemorySize = 1024 * 64 }; @@ -86,4 +86,9 @@ private static string GetStringFromHash(byte[] hash) return result.ToString(); } + + private static int GetMaxDegreeOfParallelism(int parallelismFactor) + { + return Math.Max(1, Environment.ProcessorCount / parallelismFactor); + } } diff --git a/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs b/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs index 522e591..721b716 100644 --- a/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs +++ b/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs @@ -61,4 +61,9 @@ public T GetSection(string sectionName, T defaultValue) where T : class { return GetSection(sectionName) ?? defaultValue; } + + public T GetValue(string key) where T : struct + { + return _configuration.GetValue(key); + } } diff --git a/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs b/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs index 37cba82..69e1c96 100644 --- a/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs +++ b/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs @@ -9,4 +9,5 @@ public interface IConfigurationService string GetSecret(string key, string secretName, string defaultValue); T GetSection(string sectionName) where T : class; T GetSection(string sectionName, T defaultValue) where T : class; + T GetValue(string key) where T : struct; } diff --git a/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs index 5346cc2..96671fc 100644 --- a/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserAppServiceTests.cs @@ -4,6 +4,7 @@ using InvoiceReminder.Application.ViewModels; using InvoiceReminder.Data.Interfaces; using InvoiceReminder.Domain.Entities; +using InvoiceReminder.Domain.Services.Configuration; using Mapster; using NSubstitute; using Shouldly; @@ -13,6 +14,7 @@ namespace InvoiceReminder.UnitTests.Application.AppServices; [TestClass] public sealed class UserAppServiceTests { + private readonly IConfigurationService _configuration; private readonly IUserRepository _repository; private readonly IUnitOfWork _unitOfWork; private readonly Faker _faker; @@ -21,6 +23,7 @@ public sealed class UserAppServiceTests public UserAppServiceTests() { + _configuration = Substitute.For(); _repository = Substitute.For(); _unitOfWork = Substitute.For(); _faker = new Faker(); @@ -45,7 +48,7 @@ private static Faker CreateFaker() public void UserAppService_ShouldBeAssignableToItsInterface_And_GenericInterface_And_GenericAppService() { // Arrange && Act - var appService = new UserAppService(_repository, _unitOfWork); + var appService = new UserAppService(_configuration, _repository, _unitOfWork); // Assert appService.ShouldSatisfyAllConditions(() => @@ -60,7 +63,7 @@ public void UserAppService_ShouldBeAssignableToItsInterface_And_GenericInterface public async Task GetByEmaildAsync_WhenUserEmailExists_ShouldReturnSuccess_WhithResultFound() { // Arrange - var appService = new UserAppService(_repository, _unitOfWork); + var appService = new UserAppService(_configuration, _repository, _unitOfWork); var user = CreateFaker().Generate(); var email = user.Email; @@ -86,7 +89,7 @@ public async Task GetByEmaildAsync_WhenUserEmailExists_ShouldReturnSuccess_Whith public async Task GetByEmaildAsync_WhenUserEmailNotExists_ShouldReturnFailure_WhithNoResult() { // Arrange - var appService = new UserAppService(_repository, _unitOfWork); + var appService = new UserAppService(_configuration, _repository, _unitOfWork); var email = _faker.Internet.Email(); _ = _repository.GetByEmailAsync(Arg.Any(), Arg.Any()).Returns((User)null); diff --git a/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs index ecb4f8c..4c75ae5 100644 --- a/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs @@ -4,6 +4,7 @@ using InvoiceReminder.Application.ViewModels; using InvoiceReminder.Data.Interfaces; using InvoiceReminder.Domain.Entities; +using InvoiceReminder.Domain.Services.Configuration; using Mapster; using NSubstitute; using Shouldly; @@ -13,6 +14,7 @@ namespace InvoiceReminder.UnitTests.Application.AppServices; [TestClass] public sealed class UserPasswordAppServiceTests { + private readonly IConfigurationService _configuration; private readonly IUserPasswordRepository _repository; private readonly IUnitOfWork _unitOfWork; private readonly Faker _faker; @@ -21,9 +23,12 @@ public sealed class UserPasswordAppServiceTests public UserPasswordAppServiceTests() { + _configuration = Substitute.For(); _repository = Substitute.For(); _unitOfWork = Substitute.For(); _faker = new Faker(); + + _ = _configuration.GetValue("Security:ParallelismFactor").Returns(2); } private static Faker CreateUserPasswordFaker() @@ -52,7 +57,7 @@ private static Faker CreateUserPasswordViewModelFaker() public void UserPasswordAppService_ShouldBeAssignableToItsInterface_And_GenericInterface_And_GenericAppService() { // Arrange && Act - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); // Assert appService.ShouldSatisfyAllConditions(() => @@ -69,7 +74,7 @@ public void UserPasswordAppService_ShouldBeAssignableToItsInterface_And_GenericI public async Task AddAsync_Should_Hash_Password_Before_Adding() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var viewModel = CreateUserPasswordViewModelFaker().Generate(); var plainPassword = viewModel.PasswordHash; @@ -101,7 +106,7 @@ public async Task AddAsync_Should_Hash_Password_Before_Adding() public async Task AddAsync_Should_Return_Failure_When_ViewModel_Is_Null() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); // Act var result = await appService.AddAsync(null, TestContext.CancellationToken); @@ -119,7 +124,7 @@ public async Task AddAsync_Should_Return_Failure_When_ViewModel_Is_Null() public async Task AddAsync_Should_Generate_Different_Hash_For_Same_Password() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var password = _faker.Internet.Password(12, false, "[A-Z]", "abc123"); var viewModel1 = CreateUserPasswordViewModelFaker() @@ -158,7 +163,7 @@ public async Task AddAsync_Should_Generate_Different_Hash_For_Same_Password() public async Task BulkInsertAsync_Should_Hash_All_Passwords_Before_Inserting() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var viewModels = CreateUserPasswordViewModelFaker().Generate(3).ToList(); var plainPasswords = viewModels.Select(v => v.PasswordHash).ToList(); @@ -184,7 +189,7 @@ public async Task BulkInsertAsync_Should_Hash_All_Passwords_Before_Inserting() public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); // Act var result = await appService.BulkInsertAsync(null, TestContext.CancellationToken); @@ -202,7 +207,7 @@ public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Empty() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); // Act var result = await appService.BulkInsertAsync([], TestContext.CancellationToken); @@ -224,7 +229,7 @@ public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Empt public async Task UpdateAsync_Should_Hash_Password_Before_Updating() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var viewModel = CreateUserPasswordViewModelFaker().Generate(); var plainPassword = viewModel.PasswordHash; @@ -255,7 +260,7 @@ public async Task UpdateAsync_Should_Hash_Password_Before_Updating() public async Task UpdateAsync_Should_Return_Failure_When_ViewModel_Is_Null() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); // Act var result = await appService.UpdateAsync(null, TestContext.CancellationToken); @@ -277,7 +282,7 @@ public async Task UpdateAsync_Should_Return_Failure_When_ViewModel_Is_Null() public async Task GetByUserIdAsync_Should_Return_Success_When_UserPassword_Exists() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var userId = Guid.NewGuid(); var userPassword = CreateUserPasswordFaker() .RuleFor(u => u.UserId, _ => userId) @@ -306,7 +311,7 @@ public async Task GetByUserIdAsync_Should_Return_Success_When_UserPassword_Exist public async Task GetByUserIdAsync_Should_Return_Failure_When_UserPassword_NotExists() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var userId = Guid.NewGuid(); _ = _repository.GetByUserIdAsync(Arg.Any(), Arg.Any()) @@ -330,7 +335,7 @@ public async Task GetByUserIdAsync_Should_Return_Failure_When_UserPassword_NotEx public async Task GetByUserIdAsync_Should_Adapt_Entity_To_ViewModel() { // Arrange - var appService = new UserPasswordAppService(_repository, _unitOfWork); + var appService = new UserPasswordAppService(_configuration, _repository, _unitOfWork); var userPassword = CreateUserPasswordFaker().Generate(); _ = _repository.GetByUserIdAsync(Arg.Any(), Arg.Any()) diff --git a/InvoiceReminder.UnitTests.Domain/Services/Configuration/ConfigurationServiceTests.cs b/InvoiceReminder.UnitTests.Domain/Services/Configuration/ConfigurationServiceTests.cs index c4aa7b2..75ee58f 100644 --- a/InvoiceReminder.UnitTests.Domain/Services/Configuration/ConfigurationServiceTests.cs +++ b/InvoiceReminder.UnitTests.Domain/Services/Configuration/ConfigurationServiceTests.cs @@ -242,6 +242,78 @@ public void GetSection_WithDefault_Missing_ReturnsDefault() } #endregion + + #region GetValue + + [TestMethod] + public void GetValue_IntKey_Exists_ReturnsValue() + { + var service = CreateService(new() { ["MyIntValue"] = "42" }); + + var result = service.GetValue("MyIntValue"); + + result.ShouldBe(42); + } + + [TestMethod] + public void GetValue_BoolKey_Exists_ReturnsValue() + { + var service = CreateService(new() { ["MyBoolValue"] = "true" }); + + var result = service.GetValue("MyBoolValue"); + + result.ShouldBeTrue(); + } + + [TestMethod] + public void GetValue_DoubleKey_Exists_ReturnsValue() + { + var service = CreateService(new() { ["MyDoubleValue"] = "3.14" }); + + var result = service.GetValue("MyDoubleValue"); + + result.ShouldBe(3.14); + } + + [TestMethod] + public void GetValue_LongKey_Exists_ReturnsValue() + { + var service = CreateService(new() { ["MyLongValue"] = "9223372036854775807" }); + + var result = service.GetValue("MyLongValue"); + + result.ShouldBe(9223372036854775807); + } + + [TestMethod] + public void GetValue_IntKey_Missing_ReturnsDefaultValue() + { + var service = CreateService(); + + var result = service.GetValue("MissingIntValue"); + + result.ShouldBe(0); + } + + [TestMethod] + public void GetValue_BoolKey_Missing_ReturnsDefaultValue() + { + var service = CreateService(); + + var result = service.GetValue("MissingBoolValue"); + + result.ShouldBeFalse(); + } + + [TestMethod] + public void GetValue_InvalidFormat_ThrowsException() + { + var service = CreateService(new() { ["InvalidInt"] = "NotAnInteger" }); + + _ = Should.Throw(() => service.GetValue("InvalidInt")); + } + + #endregion } public class MyTestSection From f096e530e53c6e051c34423f6abaf6c5aa72a01f Mon Sep 17 00:00:00 2001 From: "Jefferson L. da Silva" Date: Tue, 20 Jan 2026 03:05:37 -0300 Subject: [PATCH 4/4] Validates password hash property Adds data annotations to enforce password complexity. Requires a minimum length of 6 and a maximum of 100 characters. --- .../ViewModels/UserPasswordViewModel.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs index 84f003d..2ec6aff 100644 --- a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs +++ b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace InvoiceReminder.Application.ViewModels; @@ -7,6 +8,8 @@ public class UserPasswordViewModel : ViewModelDefaults [JsonPropertyOrder(2)] public Guid UserId { get; set; } + [Required] + [StringLength(100, MinimumLength = 6)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public string PasswordHash { get; set; }