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..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 { - public string Email { get; set; } - public string Password { get; set; } + [Required] + [EmailAddress] + public string Email { get; init; } + + [Required] + 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..6986288 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) @@ -78,13 +78,10 @@ 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) => + _ = endpoint.MapGet("/getby-sender/{email}/{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.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/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..c42829e 100644 --- a/InvoiceReminder.Application/AppServices/UserAppService.cs +++ b/InvoiceReminder.Application/AppServices/UserAppService.cs @@ -1,22 +1,59 @@ 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 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; } - public async Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default) + public override async Task> AddAsync( + UserViewModel viewModel, + CancellationToken cancellationToken = default) + { + if (viewModel is null) + { + return Result.Failure($"Parameter {nameof(viewModel)} was Null."); + } + + 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; + 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) { var entity = await _repository.GetByEmailAsync(value, cancellationToken); @@ -24,4 +61,17 @@ 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, _parallelismFactor); + + 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..337bd2d --- /dev/null +++ b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs @@ -0,0 +1,111 @@ +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 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(IConfigurationService configuration, IUserPasswordRepository repository, IUnitOfWork unitOfWork) + : base(repository, unitOfWork) + { + _parallelismFactor = configuration.GetValue("Security:ParallelismFactor"); + _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."); + } + + if (string.IsNullOrWhiteSpace(viewModel.PasswordHash)) + { + return Result.Failure("Password is required."); + } + + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor); + + 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 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(_parallelismFactor); + + 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 object data was Null."); + } + + (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor); + + 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..57ce860 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(4)] public string Beneficiary { get; set; } [Required] + [JsonPropertyOrder(5)] public decimal Amount { get; set; } [Required] + [JsonPropertyOrder(6)] public string Barcode { get; set; } [Required] + [JsonPropertyOrder(7)] 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..2ec6aff --- /dev/null +++ b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace InvoiceReminder.Application.ViewModels; + +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; } + + [JsonIgnore] + public string PasswordSalt { get; set; } +} diff --git a/InvoiceReminder.Application/ViewModels/UserViewModel.cs b/InvoiceReminder.Application/ViewModels/UserViewModel.cs index d861f3b..6f3abce 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(Condition = JsonIgnoreCondition.WhenWriting)] + 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..7824f0f --- /dev/null +++ b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs @@ -0,0 +1,7 @@ +namespace InvoiceReminder.Authentication.Abstractions; + +public record UserClaims +{ + 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 e57c29f..22ee393 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, int parallelismFactor = 2) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputString); + + var salt = RandomNumberGenerator.GetBytes(16); + + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + { + Salt = salt, + DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor), + 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, int parallelismFactor = 2) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputString); + + var salt = Convert.FromBase64String(storedSalt); + + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString)) + { + Salt = salt, + DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor), + 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); @@ -41,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.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..8c57650 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,14 +20,24 @@ public JwtProvider(IOptions jwtOptions) _jwtOptions = jwtOptions.Value; } - public JwtObject Generate(UserViewModel user) + 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/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..1f79ac0 100644 --- a/InvoiceReminder.Data/Migrations/20250930210104_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; @@ -25,24 +26,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 +49,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 +232,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,8 +265,13 @@ 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"); } } +#pragma warning restore S1192 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..3e06a13 --- /dev/null +++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs @@ -0,0 +1,58 @@ +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.HasIndex(x => x.UserId) + .IsUnique(); + + _ = 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(); + + _ = builder.HasOne() + .WithOne(x => x.UserPassword) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade) + .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.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.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/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 2720c13..df7a403 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; @@ -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,19 +34,16 @@ 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() .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 +62,6 @@ public async Task Login_WithValidCredentials_ReturnsOkWithJwtObject() var expectedUser = _userViewModelFaker .Clone() - .RuleFor(u => u.Password, password.ToSHA256()) .Generate(); var loginRequest = new LoginRequest @@ -80,10 +77,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 +88,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 +117,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 +126,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 +147,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 +159,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 +168,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); @@ -200,8 +201,10 @@ public async Task Login_WithEmptyEmailOrPassword_ShouldReturnBadRequest() var result = await response.Content.ReadFromJsonAsync(TestContext.CancellationToken); // Assert - _ = _userAppService.DidNotReceive().GetByEmailAsync(Arg.Any(), Arg.Any()); - _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); + _ = _userAppService.DidNotReceive() + .ValidateUserPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + _ = _jwtProvider.DidNotReceive().Generate(Arg.Any()); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); @@ -221,7 +224,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 +233,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); @@ -241,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 25e1c37..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,19 +32,16 @@ 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() .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()); @@ -698,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 new file mode 100644 index 0000000..1ec62b7 --- /dev/null +++ b/InvoiceReminder.UnitTests.API/Endpoints/UserPasswordEndpointsTests.cs @@ -0,0 +1,376 @@ +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 : IDisposable +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + 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() + { + _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()) + .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 + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } +} 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..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(); @@ -32,7 +35,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()) @@ -46,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(() => @@ -61,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; @@ -87,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 new file mode 100644 index 0000000..4c75ae5 --- /dev/null +++ b/InvoiceReminder.UnitTests.Application/AppServices/UserPasswordAppServiceTests.cs @@ -0,0 +1,360 @@ +using Bogus; +using InvoiceReminder.Application.AppServices; +using InvoiceReminder.Application.Interfaces; +using InvoiceReminder.Application.ViewModels; +using InvoiceReminder.Data.Interfaces; +using InvoiceReminder.Domain.Entities; +using InvoiceReminder.Domain.Services.Configuration; +using Mapster; +using NSubstitute; +using Shouldly; + +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; + + public TestContext TestContext { get; set; } + + public UserPasswordAppServiceTests() + { + _configuration = Substitute.For(); + _repository = Substitute.For(); + _unitOfWork = Substitute.For(); + _faker = new Faker(); + + _ = _configuration.GetValue("Security:ParallelismFactor").Returns(2); + } + + 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(_configuration, _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(_configuration, _repository, _unitOfWork); + var viewModel = CreateUserPasswordViewModelFaker().Generate(); + var plainPassword = viewModel.PasswordHash; + + _ = _repository.AddAsync(Arg.Any(), Arg.Any()) + .Returns(x => x.Arg()); + + _ = _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(_configuration, _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(_configuration, _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(_configuration, _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.Any(v => plainPasswords.Contains(v.PasswordHash)).ShouldBeFalse(); + }); + } + + [TestMethod] + public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Null() + { + // Arrange + var appService = new UserPasswordAppService(_configuration, _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 object data was Null or Empty."); + }); + } + + [TestMethod] + public async Task BulkInsertAsync_Should_Return_Failure_When_ViewModels_Are_Empty() + { + // Arrange + var appService = new UserPasswordAppService(_configuration, _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 object data was Null or Empty."); + }); + } + + #endregion + + #region UpdateAsync Tests + + [TestMethod] + public async Task UpdateAsync_Should_Hash_Password_Before_Updating() + { + // Arrange + var appService = new UserPasswordAppService(_configuration, _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(_configuration, _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 object data was Null."); + }); + } + + #endregion + + #region GetByUserIdAsync Tests + + [TestMethod] + public async Task GetByUserIdAsync_Should_Return_Success_When_UserPassword_Exists() + { + // Arrange + var appService = new UserPasswordAppService(_configuration, _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(_configuration, _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(_configuration, _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.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 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..de68f6c 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" @@ -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); 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 +}