From 14c385fdb15ca8a56a0406b39e89f00018fc57bd Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:49:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20UpdateUsuario=20=E2=80=94=20handle?= =?UTF-8?q?r,=20validator,=20anti-lockout=20guard,=20revoke=20tokens=20[UD?= =?UTF-8?q?T-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/UsuariosController.cs | 30 +++- .../Usuarios/UpdateUsuarioEndpointTests.cs | 155 ++++++++++++++++++ .../UpdateUsuarioCommandHandlerTests.cs | 139 ++++++++++++++++ 3 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index 89184c6..cfa7e61 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Mvc; using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.ChangeMyPassword; using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Deactivate; using SIGCM2.Application.Usuarios.GetById; using SIGCM2.Application.Usuarios.List; using SIGCM2.Application.Usuarios.Reactivate; -using SIGCM2.Application.Usuarios.Update; -using SIGCM2.Application.Usuarios.ChangeMyPassword; using SIGCM2.Application.Usuarios.ResetPassword; +using SIGCM2.Application.Usuarios.Update; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -27,13 +27,19 @@ public sealed class UsuariosController : ControllerBase { private readonly IDispatcher _dispatcher; private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + private readonly IValidator _changePasswordValidator; public UsuariosController( IDispatcher dispatcher, - IValidator createValidator) + IValidator createValidator, + IValidator updateValidator, + IValidator changePasswordValidator) { _dispatcher = dispatcher; _createValidator = createValidator; + _updateValidator = updateValidator; + _changePasswordValidator = changePasswordValidator; } /// Creates a new user. Requires administracion:usuarios:gestionar. @@ -122,6 +128,15 @@ public sealed class UsuariosController : ControllerBase Rol: request.Rol ?? string.Empty, Activo: request.Activo ?? true); + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + var result = await _dispatcher.Send(command); return Ok(result); } @@ -176,6 +191,15 @@ public sealed class UsuariosController : ControllerBase OldPassword: request.OldPassword ?? string.Empty, NewPassword: request.NewPassword ?? string.Empty); + var validation = await _changePasswordValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + await _dispatcher.Send(command); return NoContent(); } diff --git a/tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs new file mode 100644 index 0000000..7e6f64c --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Usuarios; + +/// +/// Integration tests for PUT /api/v1/users/{id} (UDT-008 B4). +/// +[Collection("ApiIntegration")] +public sealed class UpdateUsuarioEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private readonly HttpClient _client; + private readonly SqlTestFixture _db; + + public UpdateUsuarioEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + _db = new SqlTestFixture(TestConnectionString); + } + + public async Task InitializeAsync() => await _db.InitializeAsync(); + public async Task DisposeAsync() => await _db.DisposeAsync(); + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "admin", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetAdminIdAsync() + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.ExecuteScalarAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + } + + private async Task SeedCajeroAsync(string username = "cajero_update") + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.ExecuteScalarAsync($""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('{username}', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Test', 'Usuario', 'cajero', '[]', 1, 0); + SELECT Id FROM dbo.Usuario WHERE Username = '{username}' + """); + } + + private async Task GetCajeroTokenAsync() + { + var id = await SeedCajeroAsync("cajero_update_auth"); + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_update_auth", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task PUT_Users_Id_200_Returns_Updated_Detail() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_upd_happy"); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{targetId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { nombre = "Editado", apellido = "Test", email = (string?)null, rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Editado", json.GetProperty("nombre").GetString()); + } + + [Fact] + public async Task PUT_Users_Id_400_Invalid_Email() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_upd_email"); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{targetId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = "not-an-email", rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PUT_Users_Id_400_Last_Admin_Lockout_With_Error_Key() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { nombre = "Admin", apellido = "Sys", email = (string?)null, rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("last-admin-lockout", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task PUT_Users_Id_404_Not_Found() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/9999"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PUT_Users_Id_403_No_Permission() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PUT_Users_Id_401_No_Auth() + { + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/1"); + request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs new file mode 100644 index 0000000..fab516d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs @@ -0,0 +1,139 @@ +using FluentValidation; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class UpdateUsuarioCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IRolRepository _rolRepo = Substitute.For(); + private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly UpdateUsuarioCommandHandler _handler; + + public UpdateUsuarioCommandHandlerTests() + { + _handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo); + + // Default: rol exists and is active + _rolRepo.ExistsActiveByCodigoAsync(Arg.Any(), Arg.Any()).Returns(true); + // Default: 2 active admins (no lockout) + _repo.CountActiveAdminsAsync(Arg.Any()).Returns(2); + } + + private static Usuario MakeUser(int id = 5, string rol = "cajero", bool activo = true) + => new(id, "user" + id, "$2a$12$hash", "Test", "User", null, rol, "[]", activo); + + [Fact] + public async Task Handle_Happy_Path_Updates_And_Returns_Detail() + { + var target = MakeUser(5); + var updated = new Usuario(5, "user5", "$2a$12$hash", "Pedro", "Gómez", "p@g.com", "cajero", "[]", true); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(updated); + + var cmd = new UpdateUsuarioCommand(5, "Pedro", "Gómez", "p@g.com", "cajero", true); + var result = await _handler.Handle(cmd); + + Assert.Equal("Pedro", result.Nombre); + await _repo.Received(1).UpdateAsync(5, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Target_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + var cmd = new UpdateUsuarioCommand(9999, "A", "B", null, "cajero", true); + await Assert.ThrowsAsync(() => _handler.Handle(cmd)); + } + + [Fact] + public async Task Handle_Throws_LastAdminLockoutException_When_Changing_Role_Of_Last_Admin() + { + var lastAdmin = MakeUser(1, "admin", true); + _repo.GetByIdAsync(1, Arg.Any()).Returns(lastAdmin); + _repo.CountActiveAdminsAsync(Arg.Any()).Returns(1); + + var cmd = new UpdateUsuarioCommand(1, "Admin", "Sys", null, "cajero", true); // changing rol + await Assert.ThrowsAsync(() => _handler.Handle(cmd)); + } + + [Fact] + public async Task Handle_Throws_LastAdminLockoutException_When_Deactivating_Last_Admin() + { + var lastAdmin = MakeUser(1, "admin", true); + _repo.GetByIdAsync(1, Arg.Any()).Returns(lastAdmin); + _repo.CountActiveAdminsAsync(Arg.Any()).Returns(1); + + var cmd = new UpdateUsuarioCommand(1, "Admin", "Sys", null, "admin", false); // activo=false + await Assert.ThrowsAsync(() => _handler.Handle(cmd)); + } + + [Fact] + public async Task Handle_Allows_Same_Rol_On_Last_Admin() + { + var lastAdmin = MakeUser(1, "admin", true); + var updatedAdmin = new Usuario(1, "user1", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true); + + _repo.GetByIdAsync(1, Arg.Any()).Returns(lastAdmin); + _repo.GetDetailAsync(1, Arg.Any()).Returns(updatedAdmin); + _repo.CountActiveAdminsAsync(Arg.Any()).Returns(1); + + var cmd = new UpdateUsuarioCommand(1, "Admin", "Sys", null, "admin", true); // same rol, same activo + var result = await _handler.Handle(cmd); // should NOT throw + + Assert.NotNull(result); + } + + [Fact] + public async Task Handle_Revokes_Refresh_Tokens_On_Role_Change() + { + var target = MakeUser(5, "cajero", true); + var updated = new Usuario(5, "user5", "$2a$12$hash", "Test", "User", null, "admin", "[]", true); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(updated); + + var cmd = new UpdateUsuarioCommand(5, "Test", "User", null, "admin", true); // rol changed + await _handler.Handle(cmd); + + await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(5, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Revokes_Refresh_Tokens_When_Deactivating() + { + var target = MakeUser(5, "cajero", true); + var updated = new Usuario(5, "user5", "$2a$12$hash", "Test", "User", null, "cajero", "[]", false); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(updated); + + var cmd = new UpdateUsuarioCommand(5, "Test", "User", null, "cajero", false); // activo=false + await _handler.Handle(cmd); + + await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(5, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Does_NOT_Revoke_Tokens_On_Name_Only_Change() + { + var target = MakeUser(5, "cajero", true); + var updated = new Usuario(5, "user5", "$2a$12$hash", "Nuevo", "User", null, "cajero", "[]", true); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(updated); + + var cmd = new UpdateUsuarioCommand(5, "Nuevo", "User", null, "cajero", true); // only name changed + await _handler.Handle(cmd); + + await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +}