From 5ddc5ddf02d9390a9a2eeeda9a10496c17ee8ca7 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:34:12 -0300 Subject: [PATCH 01/16] chore(udt-008): bootstrap rama feature/UDT-008 [UDT-008] From d1f7b3805b04b39653131ebe68625daafc305d6b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:36:46 -0300 Subject: [PATCH 02/16] feat(domain): V008 migration + Usuario with-methods + DomainException hierarchy [UDT-008] --- ...08__add_mustchangepassword_and_indexes.sql | 34 ++++ src/api/SIGCM2.Domain/Entities/Usuario.cs | 98 +++++++++- .../Exceptions/CannotSelfResetException.cs | 11 ++ .../Exceptions/DomainException.cs | 10 ++ .../Exceptions/LastAdminLockoutException.cs | 11 ++ .../Exceptions/UsuarioNotFoundException.cs | 15 ++ .../Domain/UsuarioTests.cs | 170 ++++++++++++++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 39 +++- 8 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 database/migrations/V008__add_mustchangepassword_and_indexes.sql create mode 100644 src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/DomainException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs diff --git a/database/migrations/V008__add_mustchangepassword_and_indexes.sql b/database/migrations/V008__add_mustchangepassword_and_indexes.sql new file mode 100644 index 0000000..3391bfd --- /dev/null +++ b/database/migrations/V008__add_mustchangepassword_and_indexes.sql @@ -0,0 +1,34 @@ +-- V008: Add MustChangePassword column + IX_Usuario_Activo_Rol index +-- Idempotent: re-runnable without errors. +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Add MustChangePassword column (idempotent via COL_LENGTH check) +IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL +BEGIN + ALTER TABLE dbo.Usuario + ADD MustChangePassword BIT NOT NULL + CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); + PRINT 'Column MustChangePassword added to dbo.Usuario.'; +END +ELSE + PRINT 'Column MustChangePassword already exists — skipping.'; +GO + +-- Compound index for listado filtrado (Activo + Rol) and anti-lockout guard +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_Usuario_Activo_Rol' + AND object_id = OBJECT_ID('dbo.Usuario') +) +BEGIN + CREATE INDEX IX_Usuario_Activo_Rol + ON dbo.Usuario(Activo, Rol) + INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); + PRINT 'Index IX_Usuario_Activo_Rol created.'; +END +ELSE + PRINT 'Index IX_Usuario_Activo_Rol already exists — skipping.'; +GO diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs index 8cef69d..aab7f18 100644 --- a/src/api/SIGCM2.Domain/Entities/Usuario.cs +++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs @@ -12,6 +12,11 @@ public sealed class Usuario public string PermisosJson { get; } public bool Activo { get; } + // UDT-008: new properties + public DateTime? FechaModificacion { get; } + public DateTime? UltimoLogin { get; } + public bool MustChangePassword { get; } + public Usuario( int id, string username, @@ -21,7 +26,10 @@ public sealed class Usuario string? email, string rol, string permisosJson, - bool activo) + bool activo, + DateTime? fechaModificacion = null, + DateTime? ultimoLogin = null, + bool mustChangePassword = false) { Id = id; Username = username; @@ -32,11 +40,14 @@ public sealed class Usuario Rol = rol; PermisosJson = permisosJson; Activo = activo; + FechaModificacion = fechaModificacion; + UltimoLogin = ultimoLogin; + MustChangePassword = mustChangePassword; } /// /// Factory for creating a new user (no Id — DB assigns via IDENTITY). - /// Defaults: Activo=true, PermisosJson="[]". + /// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false. /// public static Usuario ForCreation( string username, @@ -55,6 +66,87 @@ public sealed class Usuario email: email, rol: rol, permisosJson: "[]", - activo: true); + activo: true, + fechaModificacion: null, + ultimoLogin: null, + mustChangePassword: false); } + + // ── UDT-008: copy-with factory methods ──────────────────────────────────── + + /// + /// Returns a new instance with updated profile fields. + /// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable. + /// + public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: nombre, + apellido: apellido, + email: email, + rol: rol, + permisosJson: PermisosJson, + activo: activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: MustChangePassword); + + /// + /// Returns a new instance with a new password hash and mustChangePassword flag. + /// Sets FechaModificacion = UtcNow. + /// + public Usuario WithNewPasswordHash(string hash, bool mustChangePassword) + => new( + id: Id, + username: Username, + passwordHash: hash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: mustChangePassword); + + /// + /// Returns a new instance with only the MustChangePassword flag changed. + /// Sets FechaModificacion = UtcNow. + /// + public Usuario WithMustChangePassword(bool value) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: DateTime.UtcNow, + ultimoLogin: UltimoLogin, + mustChangePassword: value); + + /// + /// Returns a new instance with only UltimoLogin updated. + /// Does NOT touch FechaModificacion. + /// + public Usuario WithUltimoLogin(DateTime utcNow) + => new( + id: Id, + username: Username, + passwordHash: PasswordHash, + nombre: Nombre, + apellido: Apellido, + email: Email, + rol: Rol, + permisosJson: PermisosJson, + activo: Activo, + fechaModificacion: FechaModificacion, + ultimoLogin: utcNow, + mustChangePassword: MustChangePassword); } diff --git a/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs b/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs new file mode 100644 index 0000000..2199169 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when an admin attempts to reset their own password via the admin reset endpoint. +/// Admin must use the self-service change password endpoint instead. +/// +public sealed class CannotSelfResetException : DomainException +{ + public CannotSelfResetException() + : base("Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio.") { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/DomainException.cs b/src/api/SIGCM2.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..1e75b5c --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/DomainException.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Base class for all domain-level exceptions in SIGCM2. +/// +public abstract class DomainException : Exception +{ + protected DomainException(string message) : base(message) { } + protected DomainException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs b/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs new file mode 100644 index 0000000..36f1dc9 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/LastAdminLockoutException.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when an operation would remove the last active admin from the system, +/// causing a lockout condition. +/// +public sealed class LastAdminLockoutException : DomainException +{ + public LastAdminLockoutException() + : base("No se puede desactivar o cambiar el rol del último administrador activo.") { } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs new file mode 100644 index 0000000..fe9c15f --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested user does not exist in the system. +/// +public sealed class UsuarioNotFoundException : DomainException +{ + public int Id { get; } + + public UsuarioNotFoundException(int id) + : base($"El usuario con id '{id}' no existe.") + { + Id = id; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs index 576c238..ee28ce7 100644 --- a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs @@ -2,6 +2,8 @@ using SIGCM2.Domain.Entities; namespace SIGCM2.Application.Tests.Domain; +// ── UDT-008 tests ──────────────────────────────────────────────────────────── + public class UsuarioTests { // Happy path: constructor sets all properties correctly @@ -69,4 +71,172 @@ public class UsuarioTests var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false); Assert.False(usuario.Activo); } + + // ── UDT-008: new properties ─────────────────────────────────────────────── + + [Fact] + public void ForCreation_Defaults_MustChangePassword_False() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.False(u.MustChangePassword); + } + + [Fact] + public void ForCreation_Defaults_FechaModificacion_Null() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.Null(u.FechaModificacion); + } + + [Fact] + public void ForCreation_Defaults_UltimoLogin_Null() + { + var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero"); + Assert.Null(u.UltimoLogin); + } + + // ── UDT-008: WithUpdatedProfile ────────────────────────────────────────── + + [Fact] + public void WithUpdatedProfile_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithUpdatedProfile_Sets_Fields_Correctly() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false); + Assert.Equal("Pedro", updated.Nombre); + Assert.Equal("Gómez", updated.Apellido); + Assert.Equal("p@g.com", updated.Email); + Assert.Equal("cajero", updated.Rol); + Assert.False(updated.Activo); + } + + [Fact] + public void WithUpdatedProfile_Sets_FechaModificacion_To_UtcNow() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("A", "B", null, "admin", true); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + [Fact] + public void WithUpdatedProfile_Preserves_Immutable_Fields() + { + var u = MakeUsuario(); + var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true); + Assert.Equal(u.Id, updated.Id); + Assert.Equal(u.Username, updated.Username); + Assert.Equal(u.PasswordHash, updated.PasswordHash); + } + + // ── UDT-008: WithNewPasswordHash ───────────────────────────────────────── + + [Fact] + public void WithNewPasswordHash_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithNewPasswordHash_Sets_Hash_And_MustChange() + { + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true); + Assert.Equal("newhash", updated.PasswordHash); + Assert.True(updated.MustChangePassword); + } + + [Fact] + public void WithNewPasswordHash_Clears_MustChange_When_False() + { + var u = MakeUsuario(mustChangePassword: true); + var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false); + Assert.False(updated.MustChangePassword); + } + + [Fact] + public void WithNewPasswordHash_Sets_FechaModificacion() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithNewPasswordHash("hash2", false); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + // ── UDT-008: WithUltimoLogin ────────────────────────────────────────────── + + [Fact] + public void WithUltimoLogin_Returns_NewInstance() + { + var u = MakeUsuario(); + var updated = u.WithUltimoLogin(DateTime.UtcNow); + Assert.NotSame(u, updated); + } + + [Fact] + public void WithUltimoLogin_Sets_UltimoLogin() + { + var ts = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc); + var u = MakeUsuario(); + var updated = u.WithUltimoLogin(ts); + Assert.Equal(ts, updated.UltimoLogin); + } + + [Fact] + public void WithUltimoLogin_Does_NOT_Touch_FechaModificacion() + { + var u = MakeUsuario(); + var originalFecha = u.FechaModificacion; + var updated = u.WithUltimoLogin(DateTime.UtcNow); + Assert.Equal(originalFecha, updated.FechaModificacion); + } + + // ── UDT-008: WithMustChangePassword ────────────────────────────────────── + + [Fact] + public void WithMustChangePassword_Sets_Value_True() + { + var u = MakeUsuario(mustChangePassword: false); + var updated = u.WithMustChangePassword(true); + Assert.True(updated.MustChangePassword); + } + + [Fact] + public void WithMustChangePassword_Sets_FechaModificacion() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var u = MakeUsuario(); + var updated = u.WithMustChangePassword(true); + Assert.NotNull(updated.FechaModificacion); + Assert.True(updated.FechaModificacion >= before); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private static Usuario MakeUsuario(bool mustChangePassword = false) + => new( + id: 1, + username: "testuser", + passwordHash: "$2a$12$hash", + nombre: "Test", + apellido: "User", + email: "test@x.com", + rol: "admin", + permisosJson: "[]", + activo: true, + fechaModificacion: null, + ultimoLogin: null, + mustChangePassword: mustChangePassword + ); } diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index b2cbd33..783a672 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -26,6 +26,9 @@ public sealed class SqlTestFixture : IAsyncLifetime _connection = new SqlConnection(_connectionString); await _connection.OpenAsync(); + // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB + await EnsureV008SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -83,6 +86,38 @@ public sealed class SqlTestFixture : IAsyncLifetime } } + /// + /// Applies V008 schema changes idempotently to the test database. + /// Mirrors V008__add_mustchangepassword_and_indexes.sql. + /// + private async Task EnsureV008SchemaAsync() + { + const string addColumn = """ + IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL + BEGIN + ALTER TABLE dbo.Usuario + ADD MustChangePassword BIT NOT NULL + CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); + END + """; + + const string addIndex = """ + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_Usuario_Activo_Rol' + AND object_id = OBJECT_ID('dbo.Usuario') + ) + BEGIN + CREATE INDEX IX_Usuario_Activo_Rol + ON dbo.Usuario(Activo, Rol) + INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); + END + """; + + await _connection.ExecuteAsync(addColumn); + await _connection.ExecuteAsync(addIndex); + } + private async Task SeedPermisosCanonicalAsync() { const string sql = """ @@ -183,11 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime const string sql = """ SET QUOTED_IDENTIFIER ON; IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') - INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ( 'admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', - 'Administrador', 'Sistema', 'admin', '["*"]', 1 + 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0 ); """; await _connection.ExecuteAsync(sql); From 9dcd63543e65ede7b31ace2f61d71cdbc8e4f0d2 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:39:48 -0300 Subject: [PATCH 03/16] feat(auth): extend LoginResponse with username + mustChangePassword + ultimoLogin [UDT-008] --- .../Persistence/IUsuarioRepository.cs | 9 + .../Auth/Login/LoginCommandHandler.cs | 22 ++- .../Auth/Login/LoginResponseDto.cs | 4 +- .../SIGCM2.Application/Common/PagedResult.cs | 9 + .../Common/UpdateUsuarioFields.cs | 10 ++ .../Common/UsuarioListItem.cs | 14 ++ .../Common/UsuariosQuery.cs | 10 ++ .../Persistence/UsuarioRepository.cs | 161 +++++++++++++++++- .../Auth/Login/LoginCommandHandlerTests.cs | 82 ++++++++- 9 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 src/api/SIGCM2.Application/Common/PagedResult.cs create mode 100644 src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs create mode 100644 src/api/SIGCM2.Application/Common/UsuarioListItem.cs create mode 100644 src/api/SIGCM2.Application/Common/UsuariosQuery.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs index 3a6ba6c..bd1d554 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs @@ -1,3 +1,4 @@ +using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; namespace SIGCM2.Application.Abstractions.Persistence; @@ -8,4 +9,12 @@ public interface IUsuarioRepository Task GetByIdAsync(int id, CancellationToken ct = default); Task ExistsByUsernameAsync(string username, CancellationToken ct = default); Task AddAsync(Usuario usuario, CancellationToken ct = default); + + // UDT-008 + Task UpdateUltimoLoginAsync(int id, DateTime utcNow, CancellationToken ct = default); + Task> GetPagedAsync(UsuariosQuery query, CancellationToken ct = default); + Task GetDetailAsync(int id, CancellationToken ct = default); + Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default); + Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default); + Task CountActiveAdminsAsync(CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index b4d858c..796a2aa 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; @@ -17,6 +18,7 @@ public sealed class LoginCommandHandler : ICommandHandler _logger; public LoginCommandHandler( IUsuarioRepository repository, @@ -26,7 +28,8 @@ public sealed class LoginCommandHandler : ICommandHandler logger) { _repository = repository; _hasher = hasher; @@ -36,6 +39,7 @@ public sealed class LoginCommandHandler : ICommandHandler Handle(LoginCommand command) @@ -61,8 +65,18 @@ public sealed class LoginCommandHandler : ICommandHandler p.Codigo).ToArray(); @@ -72,9 +86,11 @@ public sealed class LoginCommandHandler : ICommandHandlerGeneric paged result for list queries. +public sealed record PagedResult( + IReadOnlyList Items, + int Page, + int PageSize, + int Total +); diff --git a/src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs b/src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs new file mode 100644 index 0000000..73cec23 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Common; + +/// Mutable fields for updating a usuario profile. Username and PasswordHash are immutable. +public sealed record UpdateUsuarioFields( + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo +); diff --git a/src/api/SIGCM2.Application/Common/UsuarioListItem.cs b/src/api/SIGCM2.Application/Common/UsuarioListItem.cs new file mode 100644 index 0000000..c4a1ac7 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/UsuarioListItem.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Application.Common; + +/// Light projection of a usuario for list views. +public sealed record UsuarioListItem( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + DateTime? UltimoLogin, + DateTime? FechaModificacion +); diff --git a/src/api/SIGCM2.Application/Common/UsuariosQuery.cs b/src/api/SIGCM2.Application/Common/UsuariosQuery.cs new file mode 100644 index 0000000..90ed0eb --- /dev/null +++ b/src/api/SIGCM2.Application/Common/UsuariosQuery.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing usuarios with optional filters and paging. +public sealed record UsuariosQuery( + int Page, + int PageSize, + string? Rol, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs index 54f9f2c..263aa20 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs @@ -1,5 +1,7 @@ +using System.Text; using Dapper; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; namespace SIGCM2.Infrastructure.Persistence; @@ -19,7 +21,8 @@ public sealed class UsuarioRepository : IUsuarioRepository SELECT Id, Username, PasswordHash, Nombre, Apellido, Email, - Rol, PermisosJson, Activo + Rol, PermisosJson, Activo, + FechaModificacion, UltimoLogin, MustChangePassword FROM dbo.Usuario WHERE Username = @Username AND Activo = 1 @@ -41,7 +44,8 @@ public sealed class UsuarioRepository : IUsuarioRepository SELECT Id, Username, PasswordHash, Nombre, Apellido, Email, - Rol, PermisosJson, Activo + Rol, PermisosJson, Activo, + FechaModificacion, UltimoLogin, MustChangePassword FROM dbo.Usuario WHERE Id = @Id """; @@ -94,6 +98,136 @@ public sealed class UsuarioRepository : IUsuarioRepository return id; } + // UDT-008 ───────────────────────────────────────────────────────────────── + + public async Task UpdateUltimoLoginAsync(int id, DateTime utcNow, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Usuario SET UltimoLogin = @Utc WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + await connection.ExecuteAsync(sql, new { Utc = utcNow, Id = id }); + } + + public async Task> GetPagedAsync(UsuariosQuery query, CancellationToken ct = default) + { + // Clamp paging params + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (!string.IsNullOrWhiteSpace(query.Rol)) + { + where.Append(" AND Rol = @Rol"); + parameters.Add("Rol", query.Rol); + } + + if (query.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", query.Activo.Value ? 1 : 0); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + where.Append(" AND (Username LIKE @Search OR Nombre LIKE @Search OR Apellido LIKE @Search OR Email LIKE @Search)"); + parameters.Add("Search", $"%{query.Search}%"); + } + + var sql = $""" + SELECT + Id, Username, Nombre, Apellido, Email, Rol, Activo, UltimoLogin, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.Usuario + {where} + ORDER BY Username + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => new UsuarioListItem( + r.Id, r.Username, r.Nombre, r.Apellido, r.Email, r.Rol, r.Activo, r.UltimoLogin, r.FechaModificacion + )).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task GetDetailAsync(int id, CancellationToken ct = default) + => await GetByIdAsync(id, ct); + + public async Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Usuario + SET Nombre = @Nombre, + Apellido = @Apellido, + Email = @Email, + Rol = @Rol, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + await connection.ExecuteAsync(sql, new + { + fields.Nombre, + fields.Apellido, + fields.Email, + fields.Rol, + fields.Activo, + FechaModificacion = fechaModificacion, + Id = id + }); + } + + public async Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Usuario + SET PasswordHash = @PasswordHash, + MustChangePassword = @MustChangePassword, + FechaModificacion = SYSUTCDATETIME() + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + await connection.ExecuteAsync(sql, new + { + PasswordHash = passwordHash, + MustChangePassword = mustChangePassword ? 1 : 0, + Id = id + }); + } + + public async Task CountActiveAdminsAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Usuario WITH (NOLOCK) WHERE Activo = 1 AND Rol = 'admin' + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + return await connection.ExecuteScalarAsync(sql); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + private static Usuario MapRow(UsuarioRow row) => new( id: row.Id, @@ -104,7 +238,10 @@ public sealed class UsuarioRepository : IUsuarioRepository email: row.Email, rol: row.Rol, permisosJson: row.PermisosJson, - activo: row.Activo + activo: row.Activo, + fechaModificacion: row.FechaModificacion, + ultimoLogin: row.UltimoLogin, + mustChangePassword: row.MustChangePassword ); // Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes) @@ -117,6 +254,22 @@ public sealed class UsuarioRepository : IUsuarioRepository string? Email, string Rol, string PermisosJson, - bool Activo + bool Activo, + DateTime? FechaModificacion, + DateTime? UltimoLogin, + bool MustChangePassword + ); + + private sealed record UsuarioPagedRow( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + DateTime? UltimoLogin, + DateTime? FechaModificacion, + int TotalCount ); } diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index 7ceffe5..f96306c 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; @@ -19,6 +20,7 @@ public class LoginCommandHandlerTests private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly LoginCommandHandler _handler; @@ -29,6 +31,10 @@ public class LoginCommandHandlerTests _refreshGenerator.Generate().Returns("raw_refresh_token_value"); _refreshRepo.AddAsync(Arg.Any()).Returns(1); + // Default: UpdateUltimoLoginAsync succeeds silently + _repository.UpdateUltimoLoginAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + // Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any(), Arg.Any()) .Returns(new List().AsReadOnly()); @@ -36,7 +42,7 @@ public class LoginCommandHandlerTests _handler = new LoginCommandHandler( _repository, _hasher, _jwtService, _refreshRepo, _refreshGenerator, _clientCtx, _authOptions, - _rolPermisoRepo); + _rolPermisoRepo, _logger); } // Scenario: valid credentials → returns token response with usuario populated @@ -243,4 +249,78 @@ public class LoginCommandHandlerTests t.ExpiresAt >= before.AddDays(6).AddHours(23) && t.ExpiresAt <= after.AddDays(7).AddSeconds(5))); } + + // ── UDT-008: username + mustChangePassword + UltimoLogin ───────────────── + + [Fact] + public async Task Handle_PopulatesUsername_InUsuarioDto() + { + var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); + _repository.GetByUsernameAsync("jperez").Returns(usuario); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); + + var result = await _handler.Handle(new LoginCommand("jperez", "pass")); + + Assert.Equal("jperez", result.Usuario.Username); + } + + [Fact] + public async Task Handle_PopulatesMustChangePassword_False_WhenZero() + { + var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true, + mustChangePassword: false); + _repository.GetByUsernameAsync("jperez").Returns(usuario); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); + + var result = await _handler.Handle(new LoginCommand("jperez", "pass")); + + Assert.False(result.Usuario.MustChangePassword); + } + + [Fact] + public async Task Handle_PopulatesMustChangePassword_True_WhenSet() + { + var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true, + mustChangePassword: true); + _repository.GetByUsernameAsync("jperez").Returns(usuario); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); + + var result = await _handler.Handle(new LoginCommand("jperez", "pass")); + + Assert.True(result.Usuario.MustChangePassword); + } + + [Fact] + public async Task Handle_CallsUpdateUltimoLoginAsync_AfterSuccessfulAuth() + { + var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); + _repository.GetByUsernameAsync("jperez").Returns(usuario); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); + + await _handler.Handle(new LoginCommand("jperez", "pass")); + + await _repository.Received(1).UpdateUltimoLoginAsync(1, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Succeeds_EvenIf_UpdateUltimoLogin_Throws() + { + var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); + _repository.GetByUsernameAsync("jperez").Returns(usuario); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); + + // Simulate DB hiccup on UltimoLogin update + _repository.UpdateUltimoLoginAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new Exception("DB timeout"))); + + // Login must still succeed + var result = await _handler.Handle(new LoginCommand("jperez", "pass")); + Assert.NotNull(result); + Assert.NotNull(result.AccessToken); + } } From 292533678393225a51bd8349eecabc1f4608fd7c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:46:23 -0300 Subject: [PATCH 04/16] =?UTF-8?q?feat(api):=20List=20+=20GetById=20usuario?= =?UTF-8?q?s=20=E2=80=94=20handlers,=20repo,=20endpoints=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/UsuariosController.cs | 175 +++++++++++++++++- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 50 +++++ .../Common/TempPasswordGenerator.cs | 43 +++++ src/api/SIGCM2.Application/Common/Unit.cs | 7 + .../SIGCM2.Application/DependencyInjection.cs | 17 ++ .../ChangeMyPasswordCommand.cs | 7 + .../ChangeMyPasswordCommandHandler.cs | 37 ++++ .../ChangeMyPasswordCommandValidator.cs | 34 ++++ .../Deactivate/DeactivateUsuarioCommand.cs | 3 + .../DeactivateUsuarioCommandHandler.cs | 54 ++++++ .../Usuarios/GetById/GetUsuarioByIdQuery.cs | 3 + .../GetById/GetUsuarioByIdQueryHandler.cs | 34 ++++ .../Usuarios/GetById/UsuarioDetailDto.cs | 17 ++ .../Usuarios/List/ListUsuariosQuery.cs | 9 + .../Usuarios/List/ListUsuariosQueryHandler.cs | 31 ++++ .../Usuarios/List/UsuarioListItemDto.cs | 12 ++ .../Reactivate/ReactivateUsuarioCommand.cs | 3 + .../ReactivateUsuarioCommandHandler.cs | 45 +++++ .../ResetUsuarioPasswordCommand.cs | 3 + .../ResetUsuarioPasswordCommandHandler.cs | 44 +++++ .../ResetUsuarioPasswordResponse.cs | 6 + .../Usuarios/Update/UpdateUsuarioCommand.cs | 10 + .../Update/UpdateUsuarioCommandHandler.cs | 70 +++++++ .../Update/UpdateUsuarioCommandValidator.cs | 29 +++ .../Exceptions/InvalidOldPasswordException.cs | 10 + .../Usuarios/GetUsuarioByIdEndpointTests.cs | 131 +++++++++++++ .../Usuarios/ListUsuariosEndpointTests.cs | 152 +++++++++++++++ .../GetUsuarioByIdQueryHandlerTests.cs | 61 ++++++ .../Usuarios/ListUsuariosQueryHandlerTests.cs | 119 ++++++++++++ 29 files changed, 1210 insertions(+), 6 deletions(-) create mode 100644 src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs create mode 100644 src/api/SIGCM2.Application/Common/Unit.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index f9279b7..89184c6 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -3,26 +3,42 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; 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 System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; namespace SIGCM2.Api.Controllers; +/// +/// UDT-001/UDT-008: Usuario management endpoints. +/// RequirePermission moved to method level to allow /me/password with [Authorize] only. +/// [ApiController] [Route("api/v1/users")] -[RequirePermission("administracion:usuarios:gestionar")] public sealed class UsuariosController : ControllerBase { private readonly IDispatcher _dispatcher; - private readonly IValidator _validator; + private readonly IValidator _createValidator; - public UsuariosController(IDispatcher dispatcher, IValidator validator) + public UsuariosController( + IDispatcher dispatcher, + IValidator createValidator) { _dispatcher = dispatcher; - _validator = validator; + _createValidator = createValidator; } - /// Creates a new user. Requires admin role. + /// Creates a new user. Requires administracion:usuarios:gestionar. [HttpPost] + [RequirePermission("administracion:usuarios:gestionar")] [ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -38,7 +54,7 @@ public sealed class UsuariosController : ControllerBase Email: request.Email, Rol: request.Rol ?? string.Empty); - var validation = await _validator.ValidateAsync(command); + var validation = await _createValidator.ValidateAsync(command); if (!validation.IsValid) { var errors = validation.Errors @@ -51,8 +67,144 @@ public sealed class UsuariosController : ControllerBase return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result); } + + /// Lists usuarios with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListUsuarios( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? rol = null, + [FromQuery] bool? activo = null, + [FromQuery] string? search = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListUsuariosQuery(page, pageSize, rol, activo, search); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single usuario by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUsuarioById([FromRoute] int id) + { + var query = new GetUsuarioByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a usuario's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request) + { + var command = new UpdateUsuarioCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Apellido: request.Apellido ?? string.Empty, + Email: request.Email, + Rol: request.Rol ?? string.Empty, + Activo: request.Activo ?? true); + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a usuario (idempotent). + [HttpPatch("{id:int}/deactivate")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateUsuario([FromRoute] int id) + { + var command = new DeactivateUsuarioCommand(id); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Reactivates a usuario (idempotent). + [HttpPatch("{id:int}/reactivate")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateUsuario([FromRoute] int id) + { + var command = new ReactivateUsuarioCommand(id); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// + /// Changes the authenticated user's own password. + /// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it). + /// Requires only authentication (no specific permission). + /// + [HttpPut("me/password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ChangeMyPassword([FromBody] ChangeMyPasswordRequest request) + { + var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new UnauthorizedAccessException(); + + var command = new ChangeMyPasswordCommand( + UsuarioId: int.Parse(sub), + OldPassword: request.OldPassword ?? string.Empty, + NewPassword: request.NewPassword ?? string.Empty); + + await _dispatcher.Send(command); + return NoContent(); + } + + /// Resets a usuario's password (admin only). Returns a one-time temp password. + [HttpPost("{id:int}/password/reset")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ResetUsuarioPassword([FromRoute] int id) + { + var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new UnauthorizedAccessException(); + + var command = new ResetUsuarioPasswordCommand( + TargetId: id, + CallerId: int.Parse(sub)); + + var result = await _dispatcher.Send(command); + return Ok(result); + } } +// ── request body records ────────────────────────────────────────────────────── + /// Create user request body — nullable to catch missing field scenarios. public sealed record CreateUsuarioRequest( string? Username, @@ -61,3 +213,14 @@ public sealed record CreateUsuarioRequest( string? Apellido, string? Email, string? Rol); + +public sealed record UpdateUsuarioRequest( + string? Nombre, + string? Apellido, + string? Email, + string? Rol, + bool? Activo); + +public sealed record ChangeMyPasswordRequest( + string? OldPassword, + string? NewPassword); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index e1911b8..f583aed 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -19,6 +19,56 @@ public sealed class ExceptionFilter : IExceptionFilter { switch (context.Exception) { + case UsuarioNotFoundException usuarioNotFoundEx: + context.Result = new ObjectResult(new + { + error = "usuario_not_found", + message = usuarioNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case LastAdminLockoutException: + context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Type = "about:blank", + Title = "last-admin-lockout", + Status = 400, + Detail = "No se puede desactivar o cambiar el rol del último administrador activo." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case CannotSelfResetException: + context.Result = new ObjectResult(new + { + error = "cannot-self-reset", + message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case InvalidOldPasswordException: + context.Result = new ObjectResult(new + { + error = "invalid-old-password", + message = "La contraseña actual es incorrecta." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + case UsernameAlreadyExistsException usernameEx: context.Result = new ObjectResult(new { diff --git a/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs b/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs new file mode 100644 index 0000000..2998f28 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SIGCM2.Application.Common; + +/// +/// Generates cryptographically secure temporary passwords. +/// Excludes visually ambiguous characters (I, O, l, o, 0, 1). +/// +public static class TempPasswordGenerator +{ + private const string UpperChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O + private const string LowerChars = "abcdefghijkmnpqrstuvwxyz"; // no l, o + private const string DigitChars = "23456789"; // no 0, 1 + private const string SymbolChars = "!@#$%&*+-=?"; // copy-paste safe + + private const string Charset = UpperChars + LowerChars + DigitChars + SymbolChars; + + public static string Generate(int length = 12) + { + if (length < 8) + throw new ArgumentOutOfRangeException(nameof(length), "Password length must be at least 8."); + + // SECURITY: NEVER log the result of this method + Span bytes = stackalloc byte[length]; + RandomNumberGenerator.Fill(bytes); + + var sb = new StringBuilder(length); + for (int i = 0; i < length; i++) + sb.Append(Charset[bytes[i] % Charset.Length]); + + var result = sb.ToString(); + + // Guarantee diversity: at least 1 upper, 1 lower, 1 digit, 1 symbol + return HasDiversity(result) ? result : Generate(length); + } + + private static bool HasDiversity(string pwd) + => pwd.Any(c => UpperChars.Contains(c)) + && pwd.Any(c => LowerChars.Contains(c)) + && pwd.Any(c => DigitChars.Contains(c)) + && pwd.Any(c => SymbolChars.Contains(c)); +} diff --git a/src/api/SIGCM2.Application/Common/Unit.cs b/src/api/SIGCM2.Application/Common/Unit.cs new file mode 100644 index 0000000..2ca3b64 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/Unit.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Common; + +/// Represents the absence of a meaningful return value. +public readonly struct Unit +{ + public static readonly Unit Value = default; +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index c864f1a..1476873 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,7 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Common; using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.GetByRol; @@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos; using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.Update; +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.ResetPassword; +using SIGCM2.Application.Usuarios.Update; namespace SIGCM2.Application; @@ -40,6 +48,15 @@ public static class DependencyInjection services.AddScoped>, GetRolPermisosQueryHandler>(); services.AddScoped>, AssignPermisosToRolCommandHandler>(); + // Usuarios (UDT-008) + services.AddScoped>, ListUsuariosQueryHandler>(); + services.AddScoped, GetUsuarioByIdQueryHandler>(); + services.AddScoped, UpdateUsuarioCommandHandler>(); + services.AddScoped, DeactivateUsuarioCommandHandler>(); + services.AddScoped, ReactivateUsuarioCommandHandler>(); + services.AddScoped, ChangeMyPasswordCommandHandler>(); + services.AddScoped, ResetUsuarioPasswordCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs new file mode 100644 index 0000000..9182179 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed record ChangeMyPasswordCommand( + int UsuarioId, + string OldPassword, + string NewPassword +); diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs new file mode 100644 index 0000000..60df389 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs @@ -0,0 +1,37 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed class ChangeMyPasswordCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IPasswordHasher _hasher; + + public ChangeMyPasswordCommandHandler( + IUsuarioRepository repository, + IPasswordHasher hasher) + { + _repository = repository; + _hasher = hasher; + } + + public async Task Handle(ChangeMyPasswordCommand cmd) + { + var user = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + if (!_hasher.Verify(cmd.OldPassword, user.PasswordHash)) + throw new InvalidOldPasswordException(); + + var newHash = _hasher.Hash(cmd.NewPassword); + await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false); + + // TODO: audit — defer to ADM-004 + // NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05) + return Unit.Value; + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs new file mode 100644 index 0000000..44c8c6e --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using SIGCM2.Application.Auth; + +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed class ChangeMyPasswordCommandValidator : AbstractValidator +{ + public ChangeMyPasswordCommandValidator(AuthOptions authOptions) + { + RuleFor(x => x.OldPassword) + .NotEmpty() + .WithMessage("La contraseña actual es requerida."); + + RuleFor(x => x.NewPassword) + .NotEmpty() + .WithMessage("La nueva contraseña es requerida.") + .MinimumLength(authOptions.PasswordMinLength) + .WithMessage($"La contraseña debe tener al menos {authOptions.PasswordMinLength} caracteres."); + + if (authOptions.PasswordRequireLetter) + { + RuleFor(x => x.NewPassword) + .Matches(@"[a-zA-Z]") + .WithMessage("La contraseña debe contener al menos una letra."); + } + + if (authOptions.PasswordRequireDigit) + { + RuleFor(x => x.NewPassword) + .Matches(@"\d") + .WithMessage("La contraseña debe contener al menos un dígito."); + } + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs new file mode 100644 index 0000000..d48fd3e --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.Deactivate; + +public sealed record DeactivateUsuarioCommand(int UsuarioId); diff --git a/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs new file mode 100644 index 0000000..48b1449 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs @@ -0,0 +1,54 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Deactivate; + +public sealed class DeactivateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public DeactivateUsuarioCommandHandler( + IUsuarioRepository repository, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(DeactivateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + // Idempotent: already inactive → return as-is without touching FechaModificacion + if (!target.Activo) + { + return new UsuarioDetailDto( + target.Id, target.Username, target.Nombre, target.Apellido, + target.Email, target.Rol, target.Activo, target.MustChangePassword, + target.UltimoLogin, target.FechaModificacion); + } + + // Guard: anti-lockout + if (target.Rol == "admin" && await _repository.CountActiveAdminsAsync() <= 1) + throw new LastAdminLockoutException(); + + var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.UsuarioId, fields, now); + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now); + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs new file mode 100644 index 0000000..7d2c1fa --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.GetById; + +public sealed record GetUsuarioByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs new file mode 100644 index 0000000..01baf7d --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs @@ -0,0 +1,34 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.GetById; + +public sealed class GetUsuarioByIdQueryHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + + public GetUsuarioByIdQueryHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetUsuarioByIdQuery query) + { + var usuario = await _repository.GetDetailAsync(query.Id) + ?? throw new UsuarioNotFoundException(query.Id); + + return new UsuarioDetailDto( + Id: usuario.Id, + Username: usuario.Username, + Nombre: usuario.Nombre, + Apellido: usuario.Apellido, + Email: usuario.Email, + Rol: usuario.Rol, + Activo: usuario.Activo, + MustChangePassword: usuario.MustChangePassword, + UltimoLogin: usuario.UltimoLogin, + FechaModificacion: usuario.FechaModificacion + ); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs b/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs new file mode 100644 index 0000000..f6b1451 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Application.Usuarios.GetById; + +/// +/// Full detail projection — excludes PasswordHash and PermisosJson (security). +/// +public sealed record UsuarioDetailDto( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + bool MustChangePassword, + DateTime? UltimoLogin, + DateTime? FechaModificacion +); diff --git a/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs new file mode 100644 index 0000000..ec0cc09 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Usuarios.List; + +public sealed record ListUsuariosQuery( + int Page, + int PageSize, + string? Rol, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs new file mode 100644 index 0000000..bbc6124 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Usuarios.List; + +public sealed class ListUsuariosQueryHandler : ICommandHandler> +{ + private readonly IUsuarioRepository _repository; + + public ListUsuariosQueryHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListUsuariosQuery query) + { + // Clamp paging params + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new UsuariosQuery(page, pageSize, query.Rol, query.Activo, query.Search); + var paged = await _repository.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(u => new UsuarioListItemDto(u.Id, u.Username, u.Nombre, u.Apellido, u.Email, u.Rol, u.Activo, u.UltimoLogin)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs b/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs new file mode 100644 index 0000000..56bb3ed --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Application.Usuarios.List; + +public sealed record UsuarioListItemDto( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + DateTime? UltimoLogin +); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs new file mode 100644 index 0000000..5a9e503 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.Reactivate; + +public sealed record ReactivateUsuarioCommand(int UsuarioId); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs new file mode 100644 index 0000000..3c56919 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs @@ -0,0 +1,45 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Reactivate; + +public sealed class ReactivateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + + public ReactivateUsuarioCommandHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task Handle(ReactivateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + // Idempotent: already active → return as-is without touching FechaModificacion + if (target.Activo) + { + return new UsuarioDetailDto( + target.Id, target.Username, target.Nombre, target.Apellido, + target.Email, target.Rol, target.Activo, target.MustChangePassword, + target.UltimoLogin, target.FechaModificacion); + } + + var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.UsuarioId, fields, now); + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs new file mode 100644 index 0000000..023deb2 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId); diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs new file mode 100644 index 0000000..502ef98 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs @@ -0,0 +1,44 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IPasswordHasher _hasher; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public ResetUsuarioPasswordCommandHandler( + IUsuarioRepository repository, + IPasswordHasher hasher, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _hasher = hasher; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(ResetUsuarioPasswordCommand cmd) + { + // Cannot self-reset: admin must use /me/password + if (cmd.CallerId == cmd.TargetId) + throw new CannotSelfResetException(); + + var target = await _repository.GetByIdAsync(cmd.TargetId) + ?? throw new UsuarioNotFoundException(cmd.TargetId); + + var temp = TempPasswordGenerator.Generate(12); + // SECURITY: NEVER log tempPassword + var hash = _hasher.Hash(temp); + + await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true); + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow); + + // TODO: audit — defer to ADM-004 + return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs new file mode 100644 index 0000000..83007a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed record ResetUsuarioPasswordResponse( + string TempPassword, + bool MustChangeOnLogin +); diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs new file mode 100644 index 0000000..707c161 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Usuarios.Update; + +public sealed record UpdateUsuarioCommand( + int Id, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo +); diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs new file mode 100644 index 0000000..02c5370 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs @@ -0,0 +1,70 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Update; + +public sealed class UpdateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IRolRepository _rolRepository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public UpdateUsuarioCommandHandler( + IUsuarioRepository repository, + IRolRepository rolRepository, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _rolRepository = rolRepository; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(UpdateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.Id) + ?? throw new UsuarioNotFoundException(cmd.Id); + + // Guard: validate rol exists and is active + var rolExists = await _rolRepository.ExistsActiveByCodigoAsync(cmd.Rol); + if (!rolExists) + throw new FluentValidation.ValidationException( + [new FluentValidation.Results.ValidationFailure("Rol", $"El rol '{cmd.Rol}' no existe o está inactivo.")]); + + // Guard: anti-lockout — cannot remove last active admin + if (target.Rol == "admin" && target.Activo) + { + var isChangingRol = !string.Equals(cmd.Rol, "admin", StringComparison.Ordinal); + var isDeactivating = !cmd.Activo; + + if ((isChangingRol || isDeactivating) + && await _repository.CountActiveAdminsAsync() <= 1) + { + throw new LastAdminLockoutException(); + } + } + + var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.Id, fields, now); + + // Revoke refresh tokens if rol changed or user deactivated + var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal); + var justDeactivated = target.Activo && !cmd.Activo; + if (rolChanged || justDeactivated) + { + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now); + } + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.Id) + ?? throw new UsuarioNotFoundException(cmd.Id); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs new file mode 100644 index 0000000..40e315b --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Usuarios.Update; + +public sealed class UpdateUsuarioCommandValidator : AbstractValidator +{ + public UpdateUsuarioCommandValidator(IRolRepository rolRepository) + { + RuleFor(x => x.Email) + .EmailAddress() + .When(x => x.Email is not null) + .WithMessage("El formato del email es inválido."); + + RuleFor(x => x.Nombre) + .NotEmpty() + .WithMessage("El nombre es requerido."); + + RuleFor(x => x.Apellido) + .NotEmpty() + .WithMessage("El apellido es requerido."); + + RuleFor(x => x.Rol) + .NotEmpty() + .WithMessage("El rol es requerido.") + .MustAsync(async (rol, ct) => await rolRepository.ExistsActiveByCodigoAsync(rol, ct)) + .WithMessage(x => $"El rol '{x.Rol}' no existe o está inactivo."); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs b/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs new file mode 100644 index 0000000..2a12a08 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a user provides an incorrect current password during password change. +/// +public sealed class InvalidOldPasswordException : DomainException +{ + public InvalidOldPasswordException() + : base("La contraseña actual es incorrecta.") { } +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs new file mode 100644 index 0000000..57088c6 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs @@ -0,0 +1,131 @@ +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 GET /api/v1/users/{id} (UDT-008 B3). +/// +[Collection("ApiIntegration")] +public sealed class GetUsuarioByIdEndpointTests : 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 GetUsuarioByIdEndpointTests(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 GetCajeroTokenAsync() + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_getbyid') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('cajero_getbyid', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0) + """); + + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_getbyid", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task GET_Users_Id_200_Returns_Detail_Shape() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(adminId, json.GetProperty("id").GetInt32()); + Assert.Equal("admin", json.GetProperty("username").GetString()); + Assert.True(json.TryGetProperty("nombre", out _)); + Assert.True(json.TryGetProperty("rol", out _)); + Assert.True(json.TryGetProperty("activo", out _)); + Assert.True(json.TryGetProperty("mustChangePassword", out _)); + } + + [Fact] + public async Task GET_Users_Id_DoesNotContain_PasswordHash_In_Response() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rawJson = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("passwordHash", rawJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("permisosJson", rawJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GET_Users_Id_9999_Returns_404() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/9999"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Id_No_Auth_Returns_401() + { + var response = await _client.GetAsync("/api/v1/users/1"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Id_No_Permission_Returns_403() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs new file mode 100644 index 0000000..8180731 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs @@ -0,0 +1,152 @@ +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 GET /api/v1/users (UDT-008 B3). +/// +[Collection("ApiIntegration")] +public sealed class ListUsuariosEndpointTests : 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 ListUsuariosEndpointTests(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 GetCajeroTokenAsync() + { + // Seed a cajero user + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_test') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('cajero_test', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0) + """); + + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_test", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + // ── happy path ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GET_Users_200_Returns_Paged_Shape() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _)); + Assert.True(json.TryGetProperty("page", out _)); + Assert.True(json.TryGetProperty("pageSize", out _)); + Assert.True(json.TryGetProperty("total", out _)); + } + + [Fact] + public async Task GET_Users_Default_PageSize_Is_20() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(20, json.GetProperty("pageSize").GetInt32()); + Assert.Equal(1, json.GetProperty("page").GetInt32()); + } + + [Fact] + public async Task GET_Users_Filter_Rol_Admin_Returns_Only_Admins() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?rol=admin"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("items").EnumerateArray().ToList(); + Assert.All(items, item => Assert.Equal("admin", item.GetProperty("rol").GetString())); + } + + [Fact] + public async Task GET_Users_PageSize_0_Returns_400() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?pageSize=0"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Page_0_Returns_400() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?page=0"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // ── auth ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GET_Users_No_Auth_Returns_401() + { + var response = await _client.GetAsync("/api/v1/users"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GET_Users_No_Permission_Returns_403() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs new file mode 100644 index 0000000..b9a7fe3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs @@ -0,0 +1,61 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class GetUsuarioByIdQueryHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly GetUsuarioByIdQueryHandler _handler; + + public GetUsuarioByIdQueryHandlerTests() + { + _handler = new GetUsuarioByIdQueryHandler(_repo); + } + + [Fact] + public async Task Handle_Returns_UsuarioDetailDto_When_Found() + { + var usuario = new Usuario(5, "jperez", "$2a$12$hash", "Juan", "Pérez", "j@x.com", "cajero", "[]", true, + fechaModificacion: null, ultimoLogin: null, mustChangePassword: false); + + _repo.GetDetailAsync(5, Arg.Any()).Returns(usuario); + + var result = await _handler.Handle(new GetUsuarioByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal("jperez", result.Username); + Assert.Equal("Juan", result.Nombre); + Assert.Equal("Pérez", result.Apellido); + Assert.Equal("j@x.com", result.Email); + Assert.Equal("cajero", result.Rol); + Assert.True(result.Activo); + Assert.False(result.MustChangePassword); + } + + [Fact] + public async Task Handle_DoesNotReturn_PasswordHash_In_Dto() + { + var usuario = new Usuario(5, "jperez", "$2a$12$SECRETHASH", "Juan", "Pérez", null, "cajero", "[]", true); + _repo.GetDetailAsync(5, Arg.Any()).Returns(usuario); + + var result = await _handler.Handle(new GetUsuarioByIdQuery(5)); + + // UsuarioDetailDto must not expose PasswordHash + var props = typeof(UsuarioDetailDto).GetProperties().Select(p => p.Name); + Assert.DoesNotContain("PasswordHash", props); + Assert.DoesNotContain("PermisosJson", props); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetDetailAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetUsuarioByIdQuery(9999))); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs new file mode 100644 index 0000000..31bdef6 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs @@ -0,0 +1,119 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.List; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ListUsuariosQueryHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly ListUsuariosQueryHandler _handler; + + public ListUsuariosQueryHandlerTests() + { + _handler = new ListUsuariosQueryHandler(_repo); + } + + [Fact] + public async Task Handle_Returns_PagedResult_With_Items() + { + var items = new List + { + new(1, "admin", "Admin", "Sys", null, "admin", true, null, null) + }; + var paged = new PagedResult(items, 1, 20, 1); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(paged); + + var query = new ListUsuariosQuery(1, 20, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(1, result.Total); + Assert.Single(result.Items); + } + + [Fact] + public async Task Handle_Clamps_PageSize_Above_100_To_100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + var query = new ListUsuariosQuery(1, 200, null, null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_Clamps_Page_Below_1_To_1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(0, 20, null, null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Rol_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, "admin", null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Rol == "admin"), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Activo_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, null, false, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Activo == false), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Search_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, null, null, "juan"); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Search == "juan"), + Arg.Any()); + } + + [Fact] + public async Task Handle_Returns_Empty_When_No_Items() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var result = await _handler.Handle(new ListUsuariosQuery(1, 20, null, null, null)); + + Assert.Equal(0, result.Total); + Assert.Empty(result.Items); + } +} From 14c385fdb15ca8a56a0406b39e89f00018fc57bd Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:49:19 -0300 Subject: [PATCH 05/16] =?UTF-8?q?feat(api):=20UpdateUsuario=20=E2=80=94=20?= =?UTF-8?q?handler,=20validator,=20anti-lockout=20guard,=20revoke=20tokens?= =?UTF-8?q?=20[UDT-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()); + } +} From 473566f255fefde2ecbd205e3f46804d0aae8447 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:50:54 -0300 Subject: [PATCH 06/16] =?UTF-8?q?feat(api):=20Deactivate=20+=20Reactivate?= =?UTF-8?q?=20usuarios=20=E2=80=94=20idempotentes,=20anti-lockout,=20revok?= =?UTF-8?q?e=20tokens=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeactivateReactivateEndpointTests.cs | 216 ++++++++++++++++++ .../DeactivateUsuarioCommandHandlerTests.cs | 97 ++++++++ .../ReactivateUsuarioCommandHandlerTests.cs | 58 +++++ 3 files changed, 371 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/DeactivateReactivateEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Usuarios/DeactivateReactivateEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/DeactivateReactivateEndpointTests.cs new file mode 100644 index 0000000..47a6e6d --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/DeactivateReactivateEndpointTests.cs @@ -0,0 +1,216 @@ +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 PATCH /api/v1/users/{id}/deactivate and /reactivate (UDT-008 B5). +/// +[Collection("ApiIntegration")] +public sealed class DeactivateReactivateEndpointTests : 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 DeactivateReactivateEndpointTests(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, bool activo = true) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + var activoVal = activo ? 1 : 0; + 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', '[]', {activoVal}, 0); + SELECT Id FROM dbo.Usuario WHERE Username = '{username}' + """); + } + + private async Task GetCajeroTokenAsync() + { + await SeedCajeroAsync("cajero_deact_auth"); + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_deact_auth", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + // ── deactivate ──────────────────────────────────────────────────────────── + + [Fact] + public async Task PATCH_Deactivate_200_Returns_UserDetail_Activo_False() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_deact_happy", true); + + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/deactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.False(json.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task PATCH_Deactivate_Idempotent_Returns_200() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_deact_idempotent", false); // already inactive + + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/deactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.False(json.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task PATCH_Deactivate_400_Last_Admin_Lockout() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{adminId}/deactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + 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 PATCH_Deactivate_404_Not_Found() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/9999/deactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PATCH_Deactivate_401_No_Auth() + { + var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/deactivate")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PATCH_Deactivate_403_No_Permission() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/deactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + // ── reactivate ──────────────────────────────────────────────────────────── + + [Fact] + public async Task PATCH_Reactivate_200_Returns_UserDetail_Activo_True() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_react_happy", false); // inactive + + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/reactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task PATCH_Reactivate_Idempotent_Returns_200() + { + var token = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_react_idempotent", true); // already active + + var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/reactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task PATCH_Reactivate_404_Not_Found() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/9999/reactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PATCH_Reactivate_401_No_Auth() + { + var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/reactivate")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PATCH_Reactivate_403_No_Permission() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/reactivate"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs new file mode 100644 index 0000000..a9161a3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs @@ -0,0 +1,97 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class DeactivateUsuarioCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly DeactivateUsuarioCommandHandler _handler; + + public DeactivateUsuarioCommandHandlerTests() + { + _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo); + _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_Deactivates_Active_User_Returns_Activo_False() + { + var target = MakeUser(5, "cajero", true); + var deactivated = MakeUser(5, "cajero", false); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(deactivated); + + var result = await _handler.Handle(new DeactivateUsuarioCommand(5)); + + Assert.False(result.Activo); + await _repo.Received(1).UpdateAsync(5, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Idempotent_When_Already_Inactive_No_FechaModificacion_Change() + { + var target = MakeUser(5, "cajero", false); // already inactive + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + + var result = await _handler.Handle(new DeactivateUsuarioCommand(5)); + + // Idempotent: should NOT call UpdateAsync + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Assert.False(result.Activo); + } + + [Fact] + public async Task Handle_Throws_LastAdminLockoutException_When_Last_Admin() + { + var lastAdmin = MakeUser(1, "admin", true); + _repo.GetByIdAsync(1, Arg.Any()).Returns(lastAdmin); + _repo.CountActiveAdminsAsync(Arg.Any()).Returns(1); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateUsuarioCommand(1))); + } + + [Fact] + public async Task Handle_Revokes_Refresh_Tokens_When_Deactivating_Active_User() + { + var target = MakeUser(5, "cajero", true); + var deactivated = MakeUser(5, "cajero", false); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(deactivated); + + await _handler.Handle(new DeactivateUsuarioCommand(5)); + + await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(5, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Does_NOT_Revoke_Tokens_When_Already_Inactive_Idempotent() + { + var target = MakeUser(5, "cajero", false); // already inactive + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + + await _handler.Handle(new DeactivateUsuarioCommand(5)); + + await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateUsuarioCommand(9999))); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs new file mode 100644 index 0000000..20cc2ca --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs @@ -0,0 +1,58 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ReactivateUsuarioCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly ReactivateUsuarioCommandHandler _handler; + + public ReactivateUsuarioCommandHandlerTests() + { + _handler = new ReactivateUsuarioCommandHandler(_repo); + } + + private static Usuario MakeUser(int id = 5, bool activo = false) + => new(id, "user" + id, "$2a$12$hash", "Test", "User", null, "cajero", "[]", activo); + + [Fact] + public async Task Handle_Reactivates_Inactive_User_Returns_Activo_True() + { + var target = MakeUser(5, false); + var reactivated = MakeUser(5, true); + + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + _repo.GetDetailAsync(5, Arg.Any()).Returns(reactivated); + + var result = await _handler.Handle(new ReactivateUsuarioCommand(5)); + + Assert.True(result.Activo); + await _repo.Received(1).UpdateAsync(5, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Idempotent_When_Already_Active() + { + var target = MakeUser(5, true); // already active + _repo.GetByIdAsync(5, Arg.Any()).Returns(target); + + var result = await _handler.Handle(new ReactivateUsuarioCommand(5)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateUsuarioCommand(9999))); + } +} From a3bd066f7b129266123b23dce65b8257924aed42 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:52:15 -0300 Subject: [PATCH 07/16] =?UTF-8?q?feat(api):=20ChangeMyPassword=20=E2=80=94?= =?UTF-8?q?=20validator,=20handler,=20endpoint=20PUT=20/me/password=20[UDT?= =?UTF-8?q?-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Usuarios/ChangeMyPasswordEndpointTests.cs | 130 ++++++++++++++++++ .../ChangeMyPasswordCommandHandlerTests.cs | 72 ++++++++++ 2 files changed, 202 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs new file mode 100644 index 0000000..5c3641b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs @@ -0,0 +1,130 @@ +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/me/password (UDT-008 B6). +/// +[Collection("ApiIntegration")] +public sealed class ChangeMyPasswordEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + // This hash corresponds to "@Diego550@" + private const string DefaultHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW"; + + private readonly HttpClient _client; + private readonly SqlTestFixture _db; + + public ChangeMyPasswordEndpointTests(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 SeedUserAsync(string username) + { + 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}', '{DefaultHash}', 'Test', 'User', 'cajero', '[]', 1, 0); + SELECT Id FROM dbo.Usuario WHERE Username = '{username}' + """); + } + + private async Task GetTokenAsync(string username) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username, password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task PUT_Me_Password_204_No_Content() + { + await SeedUserAsync("user_chpwd_happy"); + var token = await GetTokenAsync("user_chpwd_happy"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_400_Wrong_Old_With_Error_Key() + { + await SeedUserAsync("user_chpwd_wrongold"); + var token = await GetTokenAsync("user_chpwd_wrongold"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "WrongPassword!", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("invalid-old-password", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task PUT_Me_Password_400_Weak_New_Password() + { + await SeedUserAsync("user_chpwd_weak"); + var token = await GetTokenAsync("user_chpwd_weak"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "abc" }); // too short + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_401_No_Auth() + { + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PUT_Me_Password_Does_NOT_Require_Users_Manage_Permission() + { + // Cajero user (no users:gestionar permission) should be able to change own password + await SeedUserAsync("cajero_chpwd"); + var token = await GetTokenAsync("cajero_chpwd"); + + var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" }); + + var response = await _client.SendAsync(request); + + // Should succeed with 204, NOT 403 + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs new file mode 100644 index 0000000..4c686cf --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ChangeMyPasswordCommandHandlerTests.cs @@ -0,0 +1,72 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Usuarios.ChangeMyPassword; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ChangeMyPasswordCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly ChangeMyPasswordCommandHandler _handler; + + public ChangeMyPasswordCommandHandlerTests() + { + _handler = new ChangeMyPasswordCommandHandler(_repo, _hasher); + } + + private static Usuario MakeUser(int id = 1, bool mustChangePassword = false) + => new(id, "user" + id, "$2a$12$oldhash", "Test", "User", null, "cajero", "[]", true, + mustChangePassword: mustChangePassword); + + [Fact] + public async Task Handle_Happy_Path_Hashes_New_Password_Clears_MustChange() + { + var user = MakeUser(1, mustChangePassword: true); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify("oldPass1!", "$2a$12$oldhash").Returns(true); + _hasher.Hash("newPass2!").Returns("$2a$12$newhash"); + + await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!")); + + await _repo.Received(1).UpdatePasswordAsync(1, "$2a$12$newhash", false, Arg.Any()); + } + + [Fact] + public async Task Handle_Throws_InvalidOldPasswordException_When_Wrong_Old() + { + var user = MakeUser(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(false); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ChangeMyPasswordCommand(1, "wrongPass!", "newPass2!"))); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ChangeMyPasswordCommand(9999, "old", "new1234"))); + } + + [Fact] + public async Task Handle_Does_NOT_Revoke_Own_Refresh_Tokens() + { + var user = MakeUser(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(user); + _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); + _hasher.Hash(Arg.Any()).Returns("$2a$12$newhash"); + + await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!")); + + // spec REQ-BCP-05: change password does NOT revoke own tokens + await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} From 7d96d5ff180fdf9241c3600fcea0a5f016861e71 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:55:45 -0300 Subject: [PATCH 08/16] =?UTF-8?q?feat(api):=20ResetPassword=20admin=20?= =?UTF-8?q?=E2=80=94=20TempPasswordGenerator,=20handler,=20endpoint=20POST?= =?UTF-8?q?=20/{id}/password/reset=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 7: POST /api/v1/users/{id}/password/reset (admin only). - TempPasswordGenerator: RandomNumberGenerator.Fill, 12-char min, full charset diversity, never logs result - ResetUsuarioPasswordCommandHandler: self-reset guard, 404, hash, mustChangePassword=true, revoke all tokens - ExceptionFilter: CannotSelfResetException → 400 {error: cannot-self-reset} - Unit tests: TempPasswordGeneratorTests (8), ResetUsuarioPasswordCommandHandlerTests (5) - Integration tests: ResetPasswordEndpointTests (6) — 200/length/self-reset/404/401/403 --- .../Usuarios/ResetPasswordEndpointTests.cs | 153 ++++++++++++++++++ .../Common/TempPasswordGeneratorTests.cs | 82 ++++++++++ ...ResetUsuarioPasswordCommandHandlerTests.cs | 76 +++++++++ 3 files changed, 311 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Common/TempPasswordGeneratorTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs new file mode 100644 index 0000000..6ce411e --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs @@ -0,0 +1,153 @@ +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 POST /api/v1/users/{id}/password/reset (UDT-008 B7). +/// +[Collection("ApiIntegration")] +public sealed class ResetPasswordEndpointTests : 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 ResetPasswordEndpointTests(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) + { + 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', 'User', 'cajero', '[]', 1, 0); + SELECT Id FROM dbo.Usuario WHERE Username = '{username}' + """); + } + + private async Task GetCajeroTokenAsync() + { + await SeedCajeroAsync("cajero_reset_auth"); + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_reset_auth", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task POST_Password_Reset_200_Returns_TempPassword() + { + var adminToken = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_reset_happy"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("tempPassword", out var tempProp)); + Assert.False(string.IsNullOrWhiteSpace(tempProp.GetString())); + } + + [Fact] + public async Task POST_Password_Reset_TempPassword_Length_Gte_12() + { + var adminToken = await GetAdminTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_reset_length"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + var tempPassword = json.GetProperty("tempPassword").GetString()!; + Assert.True(tempPassword.Length >= 12, $"TempPassword too short: {tempPassword.Length}"); + } + + [Fact] + public async Task POST_Password_Reset_400_Cannot_Self_Reset() + { + var adminToken = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{adminId}/password/reset"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("cannot-self-reset", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task POST_Password_Reset_404_Target_Not_Found() + { + var adminToken = await GetAdminTokenAsync(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users/9999/password/reset"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task POST_Password_Reset_401_No_Auth() + { + var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/users/1/password/reset")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task POST_Password_Reset_403_No_Permission() + { + var cajeroToken = await GetCajeroTokenAsync(); + var targetId = await SeedCajeroAsync("cajero_reset_403_target"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cajeroToken); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Common/TempPasswordGeneratorTests.cs b/tests/SIGCM2.Application.Tests/Common/TempPasswordGeneratorTests.cs new file mode 100644 index 0000000..1f2308d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Common/TempPasswordGeneratorTests.cs @@ -0,0 +1,82 @@ +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Tests.Common; + +public class TempPasswordGeneratorTests +{ + [Fact] + public void Generate_Default_Length_Is_12() + { + var pwd = TempPasswordGenerator.Generate(); + Assert.Equal(12, pwd.Length); + } + + [Fact] + public void Generate_Always_Has_Uppercase_Letter() + { + for (int i = 0; i < 20; i++) + { + var pwd = TempPasswordGenerator.Generate(); + Assert.True(pwd.Any(char.IsUpper), $"No uppercase found in: {pwd}"); + } + } + + [Fact] + public void Generate_Always_Has_Lowercase_Letter() + { + for (int i = 0; i < 20; i++) + { + var pwd = TempPasswordGenerator.Generate(); + Assert.True(pwd.Any(char.IsLower), $"No lowercase found in: {pwd}"); + } + } + + [Fact] + public void Generate_Always_Has_Digit() + { + for (int i = 0; i < 20; i++) + { + var pwd = TempPasswordGenerator.Generate(); + Assert.True(pwd.Any(char.IsDigit), $"No digit found in: {pwd}"); + } + } + + [Fact] + public void Generate_Always_Has_Special_Char() + { + const string symbols = "!@#$%&*+-=?"; + for (int i = 0; i < 20; i++) + { + var pwd = TempPasswordGenerator.Generate(); + Assert.True(pwd.Any(c => symbols.Contains(c)), $"No symbol found in: {pwd}"); + } + } + + [Fact] + public void Generate_Below_8_Throws_ArgumentOutOfRangeException() + { + Assert.Throws(() => TempPasswordGenerator.Generate(7)); + } + + [Fact] + public void Generate_100_Samples_All_Pass_Diversity() + { + const string symbols = "!@#$%&*+-=?"; + for (int i = 0; i < 100; i++) + { + var pwd = TempPasswordGenerator.Generate(12); + Assert.True(pwd.Length >= 12); + Assert.True(pwd.Any(char.IsUpper)); + Assert.True(pwd.Any(char.IsLower)); + Assert.True(pwd.Any(char.IsDigit)); + Assert.True(pwd.Any(c => symbols.Contains(c))); + } + } + + [Fact] + public void Generate_Custom_Length_Respects_Length() + { + var pwd = TempPasswordGenerator.Generate(16); + Assert.Equal(16, pwd.Length); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs new file mode 100644 index 0000000..d7a92ab --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs @@ -0,0 +1,76 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Usuarios.ResetPassword; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ResetUsuarioPasswordCommandHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly ResetUsuarioPasswordCommandHandler _handler; + + public ResetUsuarioPasswordCommandHandlerTests() + { + _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo); + _hasher.Hash(Arg.Any()).Returns(args => "$2a$12$hashof_" + args[0]); + } + + private static Usuario MakeUser(int id = 5) + => new(id, "user" + id, "$2a$12$hash", "Test", "User", null, "cajero", "[]", true); + + [Fact] + public async Task Handle_Returns_TempPassword_MinLength12_With_Diversity() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(MakeUser(5)); + + var result = await _handler.Handle(new ResetUsuarioPasswordCommand(TargetId: 5, CallerId: 1)); + + Assert.True(result.TempPassword.Length >= 12, $"TempPassword too short: {result.TempPassword.Length}"); + Assert.True(result.MustChangeOnLogin); + } + + [Fact] + public async Task Handle_Calls_UpdatePasswordAsync_With_MustChange_True() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(MakeUser(5)); + + await _handler.Handle(new ResetUsuarioPasswordCommand(TargetId: 5, CallerId: 1)); + + await _repo.Received(1).UpdatePasswordAsync( + 5, + Arg.Any(), + mustChangePassword: true, + Arg.Any()); + } + + [Fact] + public async Task Handle_Revokes_All_Refresh_Tokens_Of_Target() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(MakeUser(5)); + + await _handler.Handle(new ResetUsuarioPasswordCommand(TargetId: 5, CallerId: 1)); + + await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(5, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Target_Not_Found() + { + _repo.GetByIdAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ResetUsuarioPasswordCommand(TargetId: 9999, CallerId: 1))); + } + + [Fact] + public async Task Handle_Throws_CannotSelfResetException_When_Caller_Equals_Target() + { + await Assert.ThrowsAsync( + () => _handler.Handle(new ResetUsuarioPasswordCommand(TargetId: 1, CallerId: 1))); + } +} From d998d215e06a47a8186841834994e2ac959b613d Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:02:20 -0300 Subject: [PATCH 09/16] feat(web): authStore username+mustChangePassword + MustChangePasswordGate [UDT-008] --- .../routing/MustChangePasswordGate.tsx | 28 +++++ src/web/src/features/auth/api/authApi.ts | 1 + src/web/src/features/auth/hooks/useLogin.ts | 1 + src/web/src/stores/authStore.ts | 7 ++ src/web/src/tests/api/axiosClient.test.ts | 2 +- .../routing/MustChangePasswordGate.test.tsx | 91 ++++++++++++++ .../tests/features/auth/CanPerform.test.tsx | 4 + .../tests/features/auth/LoginPage.test.tsx | 2 +- .../features/auth/ProtectedRoute.test.tsx | 9 +- .../src/tests/features/auth/useLogin.test.ts | 57 +++++++++ .../tests/features/auth/usePermission.test.ts | 5 + src/web/src/tests/stores/authStore.test.ts | 117 +++++++++++++++--- 12 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 src/web/src/components/routing/MustChangePasswordGate.tsx create mode 100644 src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx diff --git a/src/web/src/components/routing/MustChangePasswordGate.tsx b/src/web/src/components/routing/MustChangePasswordGate.tsx new file mode 100644 index 0000000..e6e6f09 --- /dev/null +++ b/src/web/src/components/routing/MustChangePasswordGate.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' + +interface MustChangePasswordGateProps { + children: ReactNode +} + +/** + * Router guard for the "must change password" flow (UDT-008). + * + * If the authenticated user has mustChangePassword=true and is NOT already + * on /perfil/contrasena, redirects them there. + * + * Place this INSIDE ProtectedRoute so it only fires for authenticated users. + * The /perfil/contrasena route itself must NOT be wrapped with this gate + * to avoid redirect loops. + */ +export function MustChangePasswordGate({ children }: MustChangePasswordGateProps) { + const user = useAuthStore((s) => s.user) + const location = useLocation() + + if (user?.mustChangePassword && location.pathname !== '/perfil/contrasena') { + return + } + + return <>{children} +} diff --git a/src/web/src/features/auth/api/authApi.ts b/src/web/src/features/auth/api/authApi.ts index 0955bfc..db42e53 100644 --- a/src/web/src/features/auth/api/authApi.ts +++ b/src/web/src/features/auth/api/authApi.ts @@ -10,6 +10,7 @@ export interface LoginResponseDto { nombre: string rol: string permisos: string[] + mustChangePassword: boolean // UDT-008 } } diff --git a/src/web/src/features/auth/hooks/useLogin.ts b/src/web/src/features/auth/hooks/useLogin.ts index b4d1f1b..aefee7f 100644 --- a/src/web/src/features/auth/hooks/useLogin.ts +++ b/src/web/src/features/auth/hooks/useLogin.ts @@ -20,6 +20,7 @@ export function useLogin() { nombre: data.usuario.nombre, rol: data.usuario.rol, permisos: data.usuario.permisos ?? [], + mustChangePassword: data.usuario.mustChangePassword ?? false, // UDT-008 }, accessToken: data.accessToken, refreshToken: data.refreshToken, diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts index 7e7ebec..1158f04 100644 --- a/src/web/src/stores/authStore.ts +++ b/src/web/src/stores/authStore.ts @@ -7,6 +7,7 @@ export interface AuthUser { nombre: string rol: string permisos: string[] + mustChangePassword: boolean // UDT-008 } interface SetAuthPayload { @@ -22,6 +23,7 @@ interface AuthState { refreshToken: string | null expiresAt: number | null // ms epoch UTC setAuth: (payload: SetAuthPayload) => void + updateUser: (patch: Partial) => void // UDT-008 updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void clearAuth: () => void logout: () => Promise @@ -43,6 +45,11 @@ export const useAuthStore = create()( expiresAt: Date.now() + payload.expiresIn * 1000, }), + updateUser: (patch) => + set((s) => ({ + user: s.user ? { ...s.user, ...patch } : null, + })), + updateAccess: (accessToken, refreshToken, expiresAt) => set({ accessToken, refreshToken, expiresAt }), diff --git a/src/web/src/tests/api/axiosClient.test.ts b/src/web/src/tests/api/axiosClient.test.ts index bfd176a..23ffdf2 100644 --- a/src/web/src/tests/api/axiosClient.test.ts +++ b/src/web/src/tests/api/axiosClient.test.ts @@ -49,7 +49,7 @@ afterEach(() => { function setAuth(accessToken: string, refreshToken: string) { useAuthStore.setState({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [], mustChangePassword: false }, accessToken, refreshToken, expiresAt: Date.now() + 3600 * 1000, diff --git a/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx b/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx new file mode 100644 index 0000000..f8ea526 --- /dev/null +++ b/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { useAuthStore } from '../../../stores/authStore' +import { MustChangePasswordGate } from '../../../components/routing/MustChangePasswordGate' + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +beforeEach(() => { + useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null }) +}) + +function renderGate(initialPath: string, mustChangePassword: boolean | null) { + if (mustChangePassword !== null) { + useAuthStore.setState({ user: { ...adminUser, mustChangePassword } }) + } + + return render( + + + Change Password Page} /> + +
Protected Content
+ + } + /> +
+
, + ) +} + +describe('MustChangePasswordGate', () => { + it('redirects to /perfil/contrasena when mustChangePassword=true and on different route', () => { + renderGate('/usuarios', true) + + expect(screen.getByText('Change Password Page')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('redirects to /perfil/contrasena when mustChangePassword=true on root', () => { + renderGate('/', true) + + expect(screen.getByText('Change Password Page')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('renders children when mustChangePassword=false', () => { + renderGate('/usuarios', false) + + expect(screen.getByText('Protected Content')).toBeInTheDocument() + expect(screen.queryByText('Change Password Page')).not.toBeInTheDocument() + }) + + it('renders children when user is null (let ProtectedRoute handle auth)', () => { + // user is null — gate should pass through, ProtectedRoute will handle it + renderGate('/usuarios', null) + + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + + it('allows render on /perfil/contrasena when mustChangePassword=true (no redirect loop)', () => { + useAuthStore.setState({ user: { ...adminUser, mustChangePassword: true } }) + + render( + + + +
Change Password Page Content
+ + } + /> +
+
, + ) + + expect(screen.getByText('Change Password Page Content')).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/auth/CanPerform.test.tsx b/src/web/src/tests/features/auth/CanPerform.test.tsx index d6f6b5e..bc41afb 100644 --- a/src/web/src/tests/features/auth/CanPerform.test.tsx +++ b/src/web/src/tests/features/auth/CanPerform.test.tsx @@ -16,6 +16,7 @@ describe('CanPerform', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -36,6 +37,7 @@ describe('CanPerform', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -68,6 +70,7 @@ describe('CanPerform', () => { nombre: 'Reportes', rol: 'reportes', permisos: [], + mustChangePassword: false, }, }) @@ -89,6 +92,7 @@ describe('CanPerform', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx index d99c99b..f06ff04 100644 --- a/src/web/src/tests/features/auth/LoginPage.test.tsx +++ b/src/web/src/tests/features/auth/LoginPage.test.tsx @@ -21,7 +21,7 @@ const mockLoginResponse = { accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', refreshToken: 'refresh-token-abc', expiresIn: 3600, - usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] }, + usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false }, } const server = setupServer( diff --git a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx index 8231c5a..59aeb99 100644 --- a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx +++ b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx @@ -71,7 +71,7 @@ describe('ProtectedRoute', () => { it('F-03-02: user autenticado sin restricciones → renderiza children', () => { useAuthStore.setState({ - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false }, }) render( @@ -101,6 +101,7 @@ describe('ProtectedRoute', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -126,7 +127,7 @@ describe('ProtectedRoute', () => { it('F-03-04: requiredRoles no coincide → redirect a /', () => { useAuthStore.setState({ - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false }, }) render( @@ -158,6 +159,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -191,6 +193,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -223,6 +226,7 @@ describe('ProtectedRoute', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -254,6 +258,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/features/auth/useLogin.test.ts b/src/web/src/tests/features/auth/useLogin.test.ts index e8ac80c..540c6a4 100644 --- a/src/web/src/tests/features/auth/useLogin.test.ts +++ b/src/web/src/tests/features/auth/useLogin.test.ts @@ -19,6 +19,7 @@ const mockLoginResponseWithPermisos = { nombre: 'Admin Sistema', rol: 'admin', permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + mustChangePassword: false, }, } @@ -32,6 +33,21 @@ const mockLoginResponseEmptyPermisos = { nombre: 'Cajero Test', rol: 'cajero', permisos: [], + mustChangePassword: false, + }, +} + +const mockLoginResponseMustChange = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + refreshToken: 'refresh-token-abc', + expiresIn: 3600, + usuario: { + id: 3, + username: 'newuser', + nombre: 'New User', + rol: 'cajero', + permisos: [], + mustChangePassword: true, }, } @@ -94,3 +110,44 @@ describe('useLogin — permisos propagation', () => { expect(state.user?.permisos).not.toBeNull() }) }) + +describe('useLogin — mustChangePassword propagation', () => { + it('F-login-03: persists mustChangePassword=false from login response', async () => { + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => + HttpResponse.json(mockLoginResponseWithPermisos, { status: 200 }), + ), + ) + + const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }) + + act(() => { + result.current.mutate({ username: 'admin', password: 'password' }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + + it('F-login-04: persists mustChangePassword=true from login response', async () => { + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => + HttpResponse.json(mockLoginResponseMustChange, { status: 200 }), + ), + ) + + const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }) + + act(() => { + result.current.mutate({ username: 'newuser', password: 'password' }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(true) + expect(state.user?.username).toBe('newuser') + }) +}) diff --git a/src/web/src/tests/features/auth/usePermission.test.ts b/src/web/src/tests/features/auth/usePermission.test.ts index e5bfdb8..30ebd73 100644 --- a/src/web/src/tests/features/auth/usePermission.test.ts +++ b/src/web/src/tests/features/auth/usePermission.test.ts @@ -16,6 +16,7 @@ describe('usePermission', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -31,6 +32,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -46,6 +48,7 @@ describe('usePermission', () => { nombre: 'Reportes', rol: 'reportes', permisos: [], + mustChangePassword: false, }, }) @@ -68,6 +71,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -85,6 +89,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index ba6b45f..942b333 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -1,6 +1,25 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { useAuthStore } from '../../stores/authStore' +// Canonical test user fixtures +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: [] as string[], + mustChangePassword: false, +} + +const cajeroUser = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [] as string[], + mustChangePassword: false, +} + describe('authStore', () => { beforeEach(() => { // Reset store state before each test @@ -28,7 +47,7 @@ describe('authStore', () => { describe('setAuth', () => { it('stores user and accessToken in state', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -43,7 +62,7 @@ describe('authStore', () => { it('persists auth data to localStorage under auth-storage key', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -61,7 +80,7 @@ describe('authStore', () => { it('setAuth_persistsRefreshTokenAndExpiresAt', () => { const before = Date.now() const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token-abc', refreshToken: 'opaque-refresh-xyz', expiresIn: 3600, @@ -92,6 +111,7 @@ describe('authStore', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + mustChangePassword: false, }, accessToken: 'access-token', refreshToken: 'refresh-token', @@ -108,7 +128,7 @@ describe('authStore', () => { it('F-04-02: setAuth con permisos vacíos → user.permisos es [] (no null)', () => { const payload = { - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: cajeroUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -120,12 +140,83 @@ describe('authStore', () => { expect(state.user?.permisos).toEqual([]) expect(state.user?.permisos).not.toBeNull() }) + + it('persists mustChangePassword=true in state and localStorage', () => { + const payload = { + user: { ...adminUser, mustChangePassword: true }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(true) + + const stored = localStorage.getItem('auth-storage') + const parsed = JSON.parse(stored!) + expect(parsed.state.user.mustChangePassword).toBe(true) + }) + + it('persists mustChangePassword=false in state', () => { + const payload = { + user: { ...adminUser, mustChangePassword: false }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + }) + + describe('updateUser', () => { + it('updateUser_patches_mustChangePassword_preserves_rest', () => { + useAuthStore.getState().setAuth({ + user: { ...adminUser, mustChangePassword: true }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + }) + + useAuthStore.getState().updateUser({ mustChangePassword: false }) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + // Other fields preserved + expect(state.user?.username).toBe('admin') + expect(state.user?.rol).toBe('admin') + expect(state.user?.id).toBe(1) + }) + + it('updateUser_noops_when_user_null', () => { + // user is null — should not throw + expect(() => useAuthStore.getState().updateUser({ mustChangePassword: false })).not.toThrow() + expect(useAuthStore.getState().user).toBeNull() + }) + + it('updateUser_can_patch_username', () => { + useAuthStore.getState().setAuth({ + user: adminUser, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + }) + + useAuthStore.getState().updateUser({ username: 'new-admin' }) + + expect(useAuthStore.getState().user?.username).toBe('new-admin') + }) }) describe('clearAuth', () => { it('F-04-03: clearAuth → user = null (permisos se limpian con el user)', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false }, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -139,7 +230,7 @@ describe('authStore', () => { it('clearAuth_removesAllFields', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -157,9 +248,8 @@ describe('authStore', () => { describe('updateAccess', () => { it('updateAccess_updatesOnlyTokens_preservesUser', () => { - const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] } useAuthStore.getState().setAuth({ - user: originalUser, + user: adminUser, accessToken: 'old-access', refreshToken: 'old-refresh', expiresIn: 3600, @@ -173,7 +263,7 @@ describe('authStore', () => { expect(state.refreshToken).toBe('new-refresh') expect(state.expiresAt).toBe(newExpiresAt) // user should be preserved - expect(state.user).toEqual(originalUser) + expect(state.user).toEqual(adminUser) }) }) @@ -181,7 +271,7 @@ describe('authStore', () => { it('logout_callsApi_thenClearsAuth', async () => { // Set up auth state with a token so logout() will try to call the API useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -201,14 +291,13 @@ describe('authStore', () => { it('logout_apiFails_stillClearsAuth', async () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, }) // Should NOT throw even if the dynamic import fails - // (We test this by verifying clearAuth is always called) let threw = false try { await useAuthStore.getState().logout() @@ -226,7 +315,7 @@ describe('authStore', () => { describe('legacy logout compatibility (via clearAuth)', () => { it('clearAuth clears user and accessToken from state', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600, @@ -241,7 +330,7 @@ describe('authStore', () => { it('clearAuth removes auth-storage from localStorage', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600, From 9512f4125d8a74f764d3d515d225a1f300728c75 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:05:07 -0300 Subject: [PATCH 10/16] =?UTF-8?q?feat(web):=20UsersListPage=20=E2=80=94=20?= =?UTF-8?q?api=20client,=20hook,=20filters,=20table,=20pagination=20[UDT-0?= =?UTF-8?q?08]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/src/features/users/api/listUsers.ts | 15 ++ .../users/components/UsersFilters.tsx | 69 ++++++ .../features/users/components/UsersTable.tsx | 73 ++++++ .../src/features/users/hooks/useUsersList.ts | 13 + .../features/users/pages/UsersListPage.tsx | 119 +++++++++ src/web/src/features/users/types.ts | 48 ++++ src/web/src/hooks/useDebouncedValue.ts | 17 ++ .../features/users/UsersListPage.test.tsx | 227 ++++++++++++++++++ .../tests/features/users/listUsers.test.ts | 70 ++++++ .../tests/features/users/useUsersList.test.ts | 67 ++++++ 10 files changed, 718 insertions(+) create mode 100644 src/web/src/features/users/api/listUsers.ts create mode 100644 src/web/src/features/users/components/UsersFilters.tsx create mode 100644 src/web/src/features/users/components/UsersTable.tsx create mode 100644 src/web/src/features/users/hooks/useUsersList.ts create mode 100644 src/web/src/features/users/pages/UsersListPage.tsx create mode 100644 src/web/src/features/users/types.ts create mode 100644 src/web/src/hooks/useDebouncedValue.ts create mode 100644 src/web/src/tests/features/users/UsersListPage.test.tsx create mode 100644 src/web/src/tests/features/users/listUsers.test.ts create mode 100644 src/web/src/tests/features/users/useUsersList.test.ts diff --git a/src/web/src/features/users/api/listUsers.ts b/src/web/src/features/users/api/listUsers.ts new file mode 100644 index 0000000..b778e9e --- /dev/null +++ b/src/web/src/features/users/api/listUsers.ts @@ -0,0 +1,15 @@ +import { axiosClient } from '@/api/axiosClient' +import type { PagedResult, UserListItem, UsuariosQuery } from '../types' + +export async function listUsers(query: UsuariosQuery): Promise> { + const params = new URLSearchParams() + + if (query.page !== undefined) params.set('page', String(query.page)) + if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) + if (query.rol !== undefined && query.rol !== '') params.set('rol', query.rol) + if (query.activo !== undefined) params.set('activo', String(query.activo)) + if (query.search !== undefined && query.search !== '') params.set('search', query.search) + + const response = await axiosClient.get>('/api/v1/users', { params }) + return response.data +} diff --git a/src/web/src/features/users/components/UsersFilters.tsx b/src/web/src/features/users/components/UsersFilters.tsx new file mode 100644 index 0000000..e21c187 --- /dev/null +++ b/src/web/src/features/users/components/UsersFilters.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react' +import { Input } from '@/components/ui/input' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' + +interface UsersFiltersProps { + onRolChange: (rol: string) => void + onActivoChange: (activo: boolean | undefined) => void + /** Called with the debounced search string (300ms) */ + onSearchChange: (search: string) => void +} + +const ROL_OPTIONS = [ + { value: '', label: 'Todos los roles' }, + { value: 'admin', label: 'Admin' }, + { value: 'cajero', label: 'Cajero' }, + { value: 'reportes', label: 'Reportes' }, +] + +export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: UsersFiltersProps) { + const [searchRaw, setSearchRaw] = useState('') + const debouncedSearch = useDebouncedValue(searchRaw, 300) + + // Propagate debounced search to parent + useEffect(() => { + onSearchChange(debouncedSearch) + }, [debouncedSearch, onSearchChange]) + + return ( +
+ {/* Search input */} + setSearchRaw(e.target.value)} + className="max-w-xs" + aria-label="Buscar usuarios" + /> + + {/* Rol select */} + + + {/* Activo filter */} + +
+ ) +} diff --git a/src/web/src/features/users/components/UsersTable.tsx b/src/web/src/features/users/components/UsersTable.tsx new file mode 100644 index 0000000..11f6151 --- /dev/null +++ b/src/web/src/features/users/components/UsersTable.tsx @@ -0,0 +1,73 @@ +import type { UserListItem } from '../types' +import { Badge } from '@/components/ui/badge' + +interface UsersTableProps { + rows: UserListItem[] + onRowClick: (user: UserListItem) => void +} + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function UsersTable({ rows, onRowClick }: UsersTableProps) { + if (rows.length === 0) { + return ( +
+ Sin resultados — no se encontraron usuarios con los filtros seleccionados. +
+ ) + } + + return ( +
+ + + + + + + + + + + + + {rows.map((u) => ( + onRowClick(u)} + className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors" + > + + + + + + + + ))} + +
UsuarioNombreEmailRolEstadoÚltimo login
{u.username}{`${u.nombre} ${u.apellido}`}{u.email ?? '—'} + + {u.rol} + + + {u.activo ? ( + + Activo + + ) : ( + + Inactivo + + )} + {formatDate(u.ultimoLogin)}
+
+ ) +} diff --git a/src/web/src/features/users/hooks/useUsersList.ts b/src/web/src/features/users/hooks/useUsersList.ts new file mode 100644 index 0000000..3592207 --- /dev/null +++ b/src/web/src/features/users/hooks/useUsersList.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { listUsers } from '../api/listUsers' +import type { UsuariosQuery } from '../types' + +export const usersListQueryKey = (query: UsuariosQuery) => ['users', 'list', query] as const + +export function useUsersList(query: UsuariosQuery) { + return useQuery({ + queryKey: usersListQueryKey(query), + queryFn: () => listUsers(query), + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/users/pages/UsersListPage.tsx b/src/web/src/features/users/pages/UsersListPage.tsx new file mode 100644 index 0000000..5aeb4df --- /dev/null +++ b/src/web/src/features/users/pages/UsersListPage.tsx @@ -0,0 +1,119 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { UsersTable } from '../components/UsersTable' +import { UsersFilters } from '../components/UsersFilters' +import { useUsersList } from '../hooks/useUsersList' +import type { UserListItem } from '../types' + +export function UsersListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [rol, setRol] = useState('') + const [activo, setActivo] = useState(undefined) + const [search, setSearch] = useState('') + + const query = { + page, + pageSize: 20, + ...(rol ? { rol } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(search ? { search } : {}), + } + + const { data, isLoading } = useUsersList(query) + + const handleRolChange = useCallback( + (newRol: string) => { + setRol(newRol) + setPage(1) + }, + [], + ) + + const handleActivoChange = useCallback( + (newActivo: boolean | undefined) => { + setActivo(newActivo) + setPage(1) + }, + [], + ) + + const handleSearchChange = useCallback( + (newSearch: string) => { + setSearch(newSearch) + setPage(1) + }, + [], + ) + + const handleRowClick = useCallback( + (user: UserListItem) => { + navigate(`/usuarios/${user.id}/editar`) + }, + [navigate], + ) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Usuarios

+ +
+ + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} usuario${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/users/types.ts b/src/web/src/features/users/types.ts new file mode 100644 index 0000000..b2a1732 --- /dev/null +++ b/src/web/src/features/users/types.ts @@ -0,0 +1,48 @@ +// UDT-008 — shared types for users feature + +export interface UserListItem { + id: number + username: string + nombre: string + apellido: string + email: string | null + rol: string + activo: boolean + ultimoLogin: string | null // ISO datetime or null +} + +export interface UserDetail { + id: number + username: string + nombre: string + apellido: string + email: string | null + rol: string + activo: boolean + mustChangePassword: boolean + ultimoLogin: string | null + fechaModificacion: string | null +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} + +export interface UsuariosQuery { + page?: number + pageSize?: number + rol?: string + activo?: boolean + search?: string +} + +export interface UpdateUserPayload { + nombre: string + apellido: string + email: string | null + rol: string + activo: boolean +} diff --git a/src/web/src/hooks/useDebouncedValue.ts b/src/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..76df41a --- /dev/null +++ b/src/web/src/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' + +/** + * Returns a debounced version of the value. + * The debounced value only updates after `delay` ms have elapsed + * since the last change. + */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debounced +} diff --git a/src/web/src/tests/features/users/UsersListPage.test.tsx b/src/web/src/tests/features/users/UsersListPage.test.tsx new file mode 100644 index 0000000..5d8da00 --- /dev/null +++ b/src/web/src/tests/features/users/UsersListPage.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { UsersListPage } from '../../../features/users/pages/UsersListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +function makeItems(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + username: `user${i + 1}`, + nombre: `Nombre${i + 1}`, + apellido: `Apellido${i + 1}`, + email: `user${i + 1}@test.com`, + rol: 'cajero', + activo: true, + ultimoLogin: null, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + Edit Page} /> + + + , + ) +} + +describe('UsersListPage', () => { + it('renders 5 rows when API returns 5 items', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument()) + // All 5 usernames visible + for (let i = 1; i <= 5; i++) { + expect(screen.getByText(`user${i}`)).toBeInTheDocument() + } + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText(/sin resultados|no se encontraron/i)).toBeInTheDocument()) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument()) + + const prevBtn = screen.getByRole('button', { name: /anterior|prev/i }) + expect(prevBtn).toBeDisabled() + }) + + it('next button disabled when on last page', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument()) + + const nextBtn = screen.getByRole('button', { name: /siguiente|next/i }) + expect(nextBtn).toBeDisabled() + }) + + it('next button enabled when more pages exist, click requests page 2', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + requests.push(request.url) + const url = new URL(request.url) + const page = parseInt(url.searchParams.get('page') ?? '1') + return HttpResponse.json({ + items: makeItems(3), + page, + pageSize: 3, + total: 6, + }) + }), + ) + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + useAuthStore.setState({ user: adminUser }) + render( + + + + + , + ) + + await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument()) + + const nextBtn = screen.getByRole('button', { name: /siguiente|next/i }) + expect(nextBtn).not.toBeDisabled() + + await userEvent.click(nextBtn) + + await waitFor(() => { + const page2Req = requests.find((u) => u.includes('page=2')) + expect(page2Req).toBeTruthy() + }) + }) + + it('selecting rol filter adds querystring rol', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + requests.push(request.url) + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + renderPage() + + await waitFor(() => expect(requests.length).toBeGreaterThan(0)) + + const rolSelect = screen.getByRole('combobox', { name: /rol/i }) + await userEvent.selectOptions(rolSelect, 'admin') + + await waitFor(() => { + const filtered = requests.find((u) => u.includes('rol=admin')) + expect(filtered).toBeTruthy() + }) + }) + + it('typing in search input triggers request with search param (debounced)', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + requests.push(request.url) + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + renderPage() + await waitFor(() => expect(requests.length).toBeGreaterThan(0)) + + const searchInput = screen.getByPlaceholderText(/buscar/i) + // Use fireEvent to type quickly without delay — then wait for debounce naturally + await userEvent.type(searchInput, 'juan') + + // After debounce (300ms + render cycles), should include search param + await waitFor( + () => { + const searched = requests.find((u) => u.includes('search=')) + expect(searched).toBeTruthy() + }, + { timeout: 3000 }, + ) + }, 10000) + + it('click row navigates to edit page', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: makeItems(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage() + + // Wait for data to load + await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument()) + + // Click on the username cell which is inside the row + const usernameCell = screen.getByText('user1') + await userEvent.click(usernameCell) + + expect(mockNavigate).toHaveBeenCalledWith('/usuarios/1/editar') + }) +}) diff --git a/src/web/src/tests/features/users/listUsers.test.ts b/src/web/src/tests/features/users/listUsers.test.ts new file mode 100644 index 0000000..7273271 --- /dev/null +++ b/src/web/src/tests/features/users/listUsers.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { listUsers } from '../../../features/users/api/listUsers' + +const API_URL = 'http://localhost:5000' + +const mockPage1 = { + items: [ + { id: 1, username: 'admin', nombre: 'Admin', apellido: 'Sistema', email: null, rol: 'admin', activo: true, ultimoLogin: null }, + { id: 2, username: 'cajero1', nombre: 'Juan', apellido: 'Pérez', email: 'j@x.com', rol: 'cajero', activo: true, ultimoLogin: '2026-04-10T10:00:00Z' }, + ], + page: 1, + pageSize: 20, + total: 2, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('listUsers api client', () => { + it('calls GET /api/v1/users and returns PagedResult', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockPage1)), + ) + + const result = await listUsers({}) + expect(result.items).toHaveLength(2) + expect(result.page).toBe(1) + expect(result.pageSize).toBe(20) + expect(result.total).toBe(2) + }) + + it('passes query params: page, pageSize, rol, activo, search', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + await listUsers({ page: 2, pageSize: 10, rol: 'cajero', activo: false, search: 'juan' }) + + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + expect(capturedUrl).toContain('rol=cajero') + expect(capturedUrl).toContain('activo=false') + expect(capturedUrl).toContain('search=juan') + }) + + it('omits undefined params from querystring', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + await listUsers({ page: 1 }) + + expect(capturedUrl).not.toContain('rol=') + expect(capturedUrl).not.toContain('activo=') + expect(capturedUrl).not.toContain('search=') + }) +}) diff --git a/src/web/src/tests/features/users/useUsersList.test.ts b/src/web/src/tests/features/users/useUsersList.test.ts new file mode 100644 index 0000000..57418d2 --- /dev/null +++ b/src/web/src/tests/features/users/useUsersList.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useUsersList } from '../../../features/users/hooks/useUsersList' + +const API_URL = 'http://localhost:5000' + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function createWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useUsersList', () => { + it('fetches page 1 by default', async () => { + server.use( + http.get(`${API_URL}/api/v1/users`, () => + HttpResponse.json({ items: [{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'S', email: null, rol: 'admin', activo: true, ultimoLogin: null }], page: 1, pageSize: 20, total: 1 }), + ), + ) + + const { result } = renderHook(() => useUsersList({}), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data?.page).toBe(1) + expect(result.current.data?.items).toHaveLength(1) + }) + + it('passes rol filter in query string', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + const { result } = renderHook(() => useUsersList({ rol: 'admin' }), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(capturedUrl).toContain('rol=admin') + }) + + it('passes activo filter', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/users`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + const { result } = renderHook(() => useUsersList({ activo: false }), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(capturedUrl).toContain('activo=false') + }) +}) From 64e0a8b5fb3a0e2132ff8305e50f262a2c254f75 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:06:54 -0300 Subject: [PATCH 11/16] =?UTF-8?q?feat(web):=20UserDetailPage=20+=20UserEdi?= =?UTF-8?q?tPage=20=E2=80=94=20get/update/deactivate/reactivate=20hooks=20?= =?UTF-8?q?y=20pages=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/users/api/deactivateUser.ts | 7 + src/web/src/features/users/api/getUser.ts | 7 + .../src/features/users/api/reactivateUser.ts | 7 + src/web/src/features/users/api/updateUser.ts | 13 + .../features/users/hooks/useDeactivateUser.ts | 13 + .../features/users/hooks/useReactivateUser.ts | 13 + .../src/features/users/hooks/useUpdateUser.ts | 15 ++ src/web/src/features/users/hooks/useUser.ts | 13 + .../features/users/pages/UserDetailPage.tsx | 96 ++++++++ .../src/features/users/pages/UserEditPage.tsx | 227 ++++++++++++++++++ .../features/users/UserEditPage.test.tsx | 141 +++++++++++ .../src/tests/features/users/getUser.test.ts | 35 +++ .../tests/features/users/updateUser.test.ts | 48 ++++ 13 files changed, 635 insertions(+) create mode 100644 src/web/src/features/users/api/deactivateUser.ts create mode 100644 src/web/src/features/users/api/getUser.ts create mode 100644 src/web/src/features/users/api/reactivateUser.ts create mode 100644 src/web/src/features/users/api/updateUser.ts create mode 100644 src/web/src/features/users/hooks/useDeactivateUser.ts create mode 100644 src/web/src/features/users/hooks/useReactivateUser.ts create mode 100644 src/web/src/features/users/hooks/useUpdateUser.ts create mode 100644 src/web/src/features/users/hooks/useUser.ts create mode 100644 src/web/src/features/users/pages/UserDetailPage.tsx create mode 100644 src/web/src/features/users/pages/UserEditPage.tsx create mode 100644 src/web/src/tests/features/users/UserEditPage.test.tsx create mode 100644 src/web/src/tests/features/users/getUser.test.ts create mode 100644 src/web/src/tests/features/users/updateUser.test.ts diff --git a/src/web/src/features/users/api/deactivateUser.ts b/src/web/src/features/users/api/deactivateUser.ts new file mode 100644 index 0000000..c8fe31a --- /dev/null +++ b/src/web/src/features/users/api/deactivateUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function deactivateUser(id: number): Promise { + const response = await axiosClient.patch(`/api/v1/users/${id}/deactivate`) + return response.data +} diff --git a/src/web/src/features/users/api/getUser.ts b/src/web/src/features/users/api/getUser.ts new file mode 100644 index 0000000..5d280e9 --- /dev/null +++ b/src/web/src/features/users/api/getUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function getUser(id: number): Promise { + const response = await axiosClient.get(`/api/v1/users/${id}`) + return response.data +} diff --git a/src/web/src/features/users/api/reactivateUser.ts b/src/web/src/features/users/api/reactivateUser.ts new file mode 100644 index 0000000..7a08ff5 --- /dev/null +++ b/src/web/src/features/users/api/reactivateUser.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail } from '../types' + +export async function reactivateUser(id: number): Promise { + const response = await axiosClient.patch(`/api/v1/users/${id}/reactivate`) + return response.data +} diff --git a/src/web/src/features/users/api/updateUser.ts b/src/web/src/features/users/api/updateUser.ts new file mode 100644 index 0000000..42992e4 --- /dev/null +++ b/src/web/src/features/users/api/updateUser.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UserDetail, UpdateUserPayload } from '../types' + +export async function updateUser(id: number, payload: UpdateUserPayload): Promise { + const response = await axiosClient.put(`/api/v1/users/${id}`, { + nombre: payload.nombre, + apellido: payload.apellido, + email: payload.email, + rol: payload.rol, + activo: payload.activo, + }) + return response.data +} diff --git a/src/web/src/features/users/hooks/useDeactivateUser.ts b/src/web/src/features/users/hooks/useDeactivateUser.ts new file mode 100644 index 0000000..5eb1685 --- /dev/null +++ b/src/web/src/features/users/hooks/useDeactivateUser.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateUser } from '../api/deactivateUser' + +export function useDeactivateUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: number) => deactivateUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useReactivateUser.ts b/src/web/src/features/users/hooks/useReactivateUser.ts new file mode 100644 index 0000000..6f2a75e --- /dev/null +++ b/src/web/src/features/users/hooks/useReactivateUser.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateUser } from '../api/reactivateUser' + +export function useReactivateUser() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: number) => reactivateUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUpdateUser.ts b/src/web/src/features/users/hooks/useUpdateUser.ts new file mode 100644 index 0000000..411e80b --- /dev/null +++ b/src/web/src/features/users/hooks/useUpdateUser.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateUser } from '../api/updateUser' +import type { UpdateUserPayload } from '../types' + +export function useUpdateUser(userId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (payload: UpdateUserPayload) => updateUser(userId, payload), + onSuccess: () => { + // Invalidate both the detail and the list + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) +} diff --git a/src/web/src/features/users/hooks/useUser.ts b/src/web/src/features/users/hooks/useUser.ts new file mode 100644 index 0000000..9a1658b --- /dev/null +++ b/src/web/src/features/users/hooks/useUser.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { getUser } from '../api/getUser' + +export const userQueryKey = (id: number) => ['users', id] as const + +export function useUser(id: number) { + return useQuery({ + queryKey: userQueryKey(id), + queryFn: () => getUser(id), + staleTime: 15_000, + enabled: id > 0, + }) +} diff --git a/src/web/src/features/users/pages/UserDetailPage.tsx b/src/web/src/features/users/pages/UserDetailPage.tsx new file mode 100644 index 0000000..2e2cbff --- /dev/null +++ b/src/web/src/features/users/pages/UserDetailPage.tsx @@ -0,0 +1,96 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { useUser } from '../hooks/useUser' +import { useDeactivateUser } from '../hooks/useDeactivateUser' +import { useReactivateUser } from '../hooks/useReactivateUser' + +export function UserDetailPage() { + const { id } = useParams<{ id: string }>() + const userId = Number(id) + const navigate = useNavigate() + + const { data: user, isLoading } = useUser(userId) + const { mutate: deactivate, isPending: deactivating } = useDeactivateUser() + const { mutate: reactivate, isPending: reactivating } = useReactivateUser() + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!user) { + return ( +
+ Usuario no encontrado. +
+ ) + } + + const busy = deactivating || reactivating + + return ( +
+
+

+ {user.nombre} {user.apellido} +

+ +
+ +
+
+ Usuario + {user.username} +
+
+ Email + {user.email ?? '—'} +
+
+ Rol + {user.rol} +
+
+ Estado + {user.activo + ? Activo + : Inactivo + } +
+
+ +
+ + + {user.activo ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx new file mode 100644 index 0000000..bc4b7c6 --- /dev/null +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -0,0 +1,227 @@ +import { useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { useUser } from '../hooks/useUser' +import { useUpdateUser } from '../hooks/useUpdateUser' + +const editSchema = z.object({ + nombre: z.string().min(1, 'El nombre es requerido'), + apellido: z.string().min(1, 'El apellido es requerido'), + email: z.string().email('Email inválido').optional().or(z.literal('')), + rol: z.string().min(1, 'Seleccioná un rol válido'), + activo: z.boolean(), +}) + +type EditFormValues = z.infer + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { title?: string; error?: string; message?: string } + if (data.title === 'last-admin-lockout' || data.error === 'last-admin-lockout') { + return 'No podés cambiar el rol o desactivar al último administrador activo' + } + return data.message ?? data.error ?? 'Error al actualizar el usuario' + } + return 'Error al actualizar el usuario' +} + +export function UserEditPage() { + const { id } = useParams<{ id: string }>() + const userId = Number(id) + const navigate = useNavigate() + + const { data: user, isLoading } = useUser(userId) + const { mutate, isPending, error } = useUpdateUser(userId) + + const form = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { + nombre: '', + apellido: '', + email: '', + rol: '', + activo: true, + }, + }) + + // Prefill form when user data loads + useEffect(() => { + if (user) { + form.reset({ + nombre: user.nombre, + apellido: user.apellido, + email: user.email ?? '', + rol: user.rol, + activo: user.activo, + }) + } + }, [user, form]) + + function handleSubmit(values: EditFormValues) { + mutate( + { + nombre: values.nombre, + apellido: values.apellido, + email: values.email || null, + rol: values.rol, + activo: values.activo, + }, + { + onSuccess: () => { + navigate('/usuarios') + }, + }, + ) + } + + const backendError = resolveBackendError(error) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!user) { + return ( +
+ Usuario no encontrado. +
+ ) + } + + return ( +
+
+

Editar Usuario

+ +
+ + {/* Username — display only, not editable */} +
+

Usuario

+

{user.username}

+
+ +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Nombre + + + + + + )} + /> + + ( + + Apellido + + + + + + )} + /> + + ( + + Email (opcional) + + + + + + )} + /> + + ( + + Rol + + + + + + )} + /> + + ( + + + + + Activo + + )} + /> + + + + +
+ ) +} diff --git a/src/web/src/tests/features/users/UserEditPage.test.tsx b/src/web/src/tests/features/users/UserEditPage.test.tsx new file mode 100644 index 0000000..b13c183 --- /dev/null +++ b/src/web/src/tests/features/users/UserEditPage.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { UserEditPage } from '../../../features/users/pages/UserEditPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +const mockUserDetail = { + id: 5, + username: 'cajero1', + nombre: 'Juan', + apellido: 'Pérez', + email: 'j@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: null, + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderEditPage(userId = 5) { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + Users List} /> + + + , + ) +} + +describe('UserEditPage', () => { + it('prefills form with user data', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + expect(screen.getByDisplayValue('Pérez')).toBeInTheDocument() + expect(screen.getByDisplayValue('j@x.com')).toBeInTheDocument() + }) + + it('username field is displayed but not an editable input', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByText('cajero1')).toBeInTheDocument()) + + // Username should NOT be an editable input + const inputs = screen.queryAllByRole('textbox') + const usernameInput = inputs.find((el) => (el as HTMLInputElement).value === 'cajero1') + expect(usernameInput).toBeUndefined() + }) + + it('submit calls PUT with correct payload then navigates to /usuarios', async () => { + let capturedBody: unknown = null + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ ...mockUserDetail, nombre: 'Pedro' }) + }), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + + // Clear and update nombre + const nombreInput = screen.getByDisplayValue('Juan') + await userEvent.clear(nombreInput) + await userEvent.type(nombreInput, 'Pedro') + + // Submit + await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/usuarios')) + expect(capturedBody).toMatchObject({ nombre: 'Pedro' }) + }) + + it('shows last-admin-lockout error message on 400', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + http.put(`${API_URL}/api/v1/users/5`, () => + HttpResponse.json( + { title: 'last-admin-lockout', status: 400 }, + { status: 400 }, + ), + ), + ) + + renderEditPage() + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i })) + + await waitFor(() => + expect(screen.getByText(/último administrador|last.admin.lockout/i)).toBeInTheDocument(), + ) + + // Should NOT navigate + expect(mockNavigate).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/users/getUser.test.ts b/src/web/src/tests/features/users/getUser.test.ts new file mode 100644 index 0000000..d28199b --- /dev/null +++ b/src/web/src/tests/features/users/getUser.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { getUser } from '../../../features/users/api/getUser' + +const API_URL = 'http://localhost:5000' + +const mockDetail = { + id: 5, + username: 'cajero1', + nombre: 'Juan', + apellido: 'Pérez', + email: 'j@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: '2026-04-10T10:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('getUser api client', () => { + it('calls GET /api/v1/users/:id and returns UserDetail', async () => { + server.use(http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockDetail))) + const result = await getUser(5) + expect(result.id).toBe(5) + expect(result.username).toBe('cajero1') + expect(result.mustChangePassword).toBe(false) + }) +}) diff --git a/src/web/src/tests/features/users/updateUser.test.ts b/src/web/src/tests/features/users/updateUser.test.ts new file mode 100644 index 0000000..f13b044 --- /dev/null +++ b/src/web/src/tests/features/users/updateUser.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { updateUser } from '../../../features/users/api/updateUser' + +const API_URL = 'http://localhost:5000' + +const mockDetail = { + id: 5, + username: 'cajero1', + nombre: 'Pedro', + apellido: 'Gómez', + email: 'new@x.com', + rol: 'cajero', + activo: true, + mustChangePassword: false, + ultimoLogin: null, + fechaModificacion: '2026-04-15T18:00:00Z', +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('updateUser api client', () => { + it('calls PUT /api/v1/users/:id with payload and returns updated UserDetail', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail) + }), + ) + + const result = await updateUser(5, { + nombre: 'Pedro', + apellido: 'Gómez', + email: 'new@x.com', + rol: 'cajero', + activo: true, + }) + + expect(result.nombre).toBe('Pedro') + expect(capturedBody).toMatchObject({ nombre: 'Pedro', apellido: 'Gómez', email: 'new@x.com' }) + }) +}) From 25ed0f64521b9a7824d28abd1036c3e730d18f84 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:09:59 -0300 Subject: [PATCH 12/16] =?UTF-8?q?feat(web):=20ChangeMyPasswordPage=20+=20R?= =?UTF-8?q?esetPasswordModal=20=E2=80=94=20hooks,=20pages,=20modal=20[UDT-?= =?UTF-8?q?008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/profile/api/changeMyPassword.ts | 10 ++ .../profile/hooks/useChangeMyPassword.ts | 9 + .../profile/pages/ChangeMyPasswordPage.tsx | 121 ++++++++++++++ .../features/users/api/resetUserPassword.ts | 13 ++ .../users/components/ResetPasswordModal.tsx | 157 ++++++++++++++++++ .../users/hooks/useResetUserPassword.ts | 8 + .../profile/ChangeMyPasswordPage.test.tsx | 139 ++++++++++++++++ .../users/ResetPasswordModal.test.tsx | 117 +++++++++++++ 8 files changed, 574 insertions(+) create mode 100644 src/web/src/features/profile/api/changeMyPassword.ts create mode 100644 src/web/src/features/profile/hooks/useChangeMyPassword.ts create mode 100644 src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx create mode 100644 src/web/src/features/users/api/resetUserPassword.ts create mode 100644 src/web/src/features/users/components/ResetPasswordModal.tsx create mode 100644 src/web/src/features/users/hooks/useResetUserPassword.ts create mode 100644 src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx create mode 100644 src/web/src/tests/features/users/ResetPasswordModal.test.tsx diff --git a/src/web/src/features/profile/api/changeMyPassword.ts b/src/web/src/features/profile/api/changeMyPassword.ts new file mode 100644 index 0000000..fa777fd --- /dev/null +++ b/src/web/src/features/profile/api/changeMyPassword.ts @@ -0,0 +1,10 @@ +import { axiosClient } from '@/api/axiosClient' + +export interface ChangeMyPasswordRequest { + oldPassword: string + newPassword: string +} + +export async function changeMyPassword(payload: ChangeMyPasswordRequest): Promise { + await axiosClient.put('/api/v1/users/me/password', payload) +} diff --git a/src/web/src/features/profile/hooks/useChangeMyPassword.ts b/src/web/src/features/profile/hooks/useChangeMyPassword.ts new file mode 100644 index 0000000..fdafe73 --- /dev/null +++ b/src/web/src/features/profile/hooks/useChangeMyPassword.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query' +import { changeMyPassword } from '../api/changeMyPassword' +import type { ChangeMyPasswordRequest } from '../api/changeMyPassword' + +export function useChangeMyPassword() { + return useMutation({ + mutationFn: (payload: ChangeMyPasswordRequest) => changeMyPassword(payload), + }) +} diff --git a/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx b/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx new file mode 100644 index 0000000..70d8e2c --- /dev/null +++ b/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { isAxiosError } from 'axios' +import { toast } from 'sonner' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { useChangeMyPassword } from '../hooks/useChangeMyPassword' +import { useAuthStore } from '@/stores/authStore' + +export function ChangeMyPasswordPage() { + const navigate = useNavigate() + const { mutate, isPending } = useChangeMyPassword() + const updateUser = useAuthStore((s) => s.updateUser) + + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [clientError, setClientError] = useState(null) + const [serverError, setServerError] = useState(null) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setClientError(null) + setServerError(null) + + if (newPassword !== confirmPassword) { + setClientError('Las contraseñas no coinciden') + return + } + + mutate( + { oldPassword, newPassword }, + { + onSuccess: () => { + // Clear mustChangePassword flag in store + updateUser({ mustChangePassword: false }) + toast.success('Contraseña actualizada correctamente') + navigate('/') + }, + onError: (err) => { + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; title?: string } + if (data.error === 'invalid-old-password' || data.title === 'invalid-old-password') { + setServerError('La contraseña actual es incorrecta') + return + } + } + setServerError('Error al cambiar la contraseña. Intentá nuevamente.') + }, + }, + ) + } + + return ( +
+

Cambiar contraseña

+ +
+ {clientError && ( + + + {clientError} + + )} + + {serverError && ( + + + {serverError} + + )} + +
+ + setOldPassword(e.target.value)} + disabled={isPending} + autoComplete="current-password" + aria-label="Contraseña actual" + /> +
+ +
+ + setNewPassword(e.target.value)} + disabled={isPending} + autoComplete="new-password" + aria-label="Nueva contraseña" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isPending} + autoComplete="new-password" + aria-label="Confirmar contraseña" + /> +
+ + +
+
+ ) +} diff --git a/src/web/src/features/users/api/resetUserPassword.ts b/src/web/src/features/users/api/resetUserPassword.ts new file mode 100644 index 0000000..a436b99 --- /dev/null +++ b/src/web/src/features/users/api/resetUserPassword.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' + +export interface ResetPasswordResponse { + tempPassword: string + mustChangeOnLogin: boolean +} + +export async function resetUserPassword(userId: number): Promise { + const response = await axiosClient.post( + `/api/v1/users/${userId}/password/reset`, + ) + return response.data +} diff --git a/src/web/src/features/users/components/ResetPasswordModal.tsx b/src/web/src/features/users/components/ResetPasswordModal.tsx new file mode 100644 index 0000000..3ef4823 --- /dev/null +++ b/src/web/src/features/users/components/ResetPasswordModal.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { Copy, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' +import { useResetUserPassword } from '../hooks/useResetUserPassword' + +interface ResetPasswordModalProps { + userId: number +} + +type ModalState = 'idle' | 'confirming' | 'showing-password' | 'error' + +export function ResetPasswordModal({ userId }: ResetPasswordModalProps) { + const [open, setOpen] = useState(false) + const [modalState, setModalState] = useState('idle') + const [tempPassword, setTempPassword] = useState(null) + const [copyDone, setCopyDone] = useState(false) + const [errorMsg, setErrorMsg] = useState(null) + + const { mutate, isPending } = useResetUserPassword() + + function handleOpen() { + setModalState('confirming') + setTempPassword(null) + setCopyDone(false) + setErrorMsg(null) + setOpen(true) + } + + function handleCancel() { + setOpen(false) + setModalState('idle') + } + + function handleConfirm() { + mutate(userId, { + onSuccess: (data) => { + setTempPassword(data.tempPassword) + setModalState('showing-password') + }, + onError: () => { + setErrorMsg('Error al resetear la contraseña. Intentá de nuevo.') + setModalState('error') + }, + }) + } + + async function handleCopy() { + if (tempPassword) { + await navigator.clipboard.writeText(tempPassword) + setCopyDone(true) + } + } + + return ( + + + + + + + + +
+ + Resetear contraseña + + + + +
+ + + Resetear contraseña del usuario + + + {modalState === 'confirming' && ( +
+

+ ¿Estás seguro que querés resetear la contraseña de este usuario? + Se generará una contraseña temporal y se invalidarán todas sus sesiones activas. +

+
+ + +
+
+ )} + + {modalState === 'showing-password' && tempPassword && ( +
+ + + + Esta es la única vez que verás esta contraseña. Copiála ahora. + + + +
+

{tempPassword}

+
+ + + + +
+ )} + + {modalState === 'error' && ( +
+ + + {errorMsg} + +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/src/web/src/features/users/hooks/useResetUserPassword.ts b/src/web/src/features/users/hooks/useResetUserPassword.ts new file mode 100644 index 0000000..7a05cdf --- /dev/null +++ b/src/web/src/features/users/hooks/useResetUserPassword.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query' +import { resetUserPassword } from '../api/resetUserPassword' + +export function useResetUserPassword() { + return useMutation({ + mutationFn: (userId: number) => resetUserPassword(userId), + }) +} diff --git a/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx b/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx new file mode 100644 index 0000000..4ac6336 --- /dev/null +++ b/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { ChangeMyPasswordPage } from '../../../features/profile/pages/ChangeMyPasswordPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const authUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: [], mustChangePassword: true, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + useAuthStore.setState({ user: authUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + Home} /> + + + , + ) +} + +// Helper: get form fields by their input id +function getOldPasswordInput() { return screen.getByLabelText('Contraseña actual') } +function getNewPasswordInput() { return screen.getByLabelText('Nueva contraseña') } +function getConfirmPasswordInput() { return screen.getByLabelText('Confirmar nueva contraseña') } +function getSubmitButton() { return screen.getByRole('button', { name: /cambiar contraseña/i }) } + +describe('ChangeMyPasswordPage', () => { + it('shows validation error when passwords do not match', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => { + throw new Error('Should not be called') + }), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'DifferentPass456') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => + expect(screen.getByText(/no coinciden/i)).toBeInTheDocument(), + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('no HTTP call when passwords do not match', async () => { + let httpCalled = false + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => { + httpCalled = true + return HttpResponse.json({}, { status: 204 }) + }), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'WrongConfirm') + + await userEvent.click(getSubmitButton()) + + await new Promise((r) => setTimeout(r, 100)) + expect(httpCalled).toBe(false) + }) + + it('submit success → updates authStore mustChangePassword to false + navigate home', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => + new HttpResponse(null, { status: 204 }), + ), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'NewPass123') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/')) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + + it('shows invalid-old-password error message on 400', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => + HttpResponse.json({ error: 'invalid-old-password' }, { status: 400 }), + ), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'wrongpassword') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'NewPass123') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => + expect(screen.getByText(/contraseña actual es incorrecta/i)).toBeInTheDocument(), + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/users/ResetPasswordModal.test.tsx b/src/web/src/tests/features/users/ResetPasswordModal.test.tsx new file mode 100644 index 0000000..5997ce1 --- /dev/null +++ b/src/web/src/tests/features/users/ResetPasswordModal.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { ResetPasswordModal } from '../../../features/users/components/ResetPasswordModal' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(userId = 5) { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('ResetPasswordModal', () => { + it('shows trigger button and modal closed by default', () => { + renderModal() + expect(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })).toBeInTheDocument() + expect(screen.queryByText(/confirmar|advertencia|única vez/i)).not.toBeInTheDocument() + }) + + it('trigger button → modal opens with confirmation', async () => { + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear contraseña/i })) + + // Modal should now show the confirm button + expect(screen.getByRole('button', { name: /confirmar/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancelar/i })).toBeInTheDocument() + }) + + it('cancel → modal closes without HTTP call', async () => { + let httpCalled = false + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => { + httpCalled = true + return HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }) + }), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /cancelar|cancel/i })) + + await new Promise((r) => setTimeout(r, 100)) + expect(httpCalled).toBe(false) + expect(screen.queryByText(/contraseña temporal|tempPassword/i)).not.toBeInTheDocument() + }) + + it('confirm → calls POST and shows tempPassword + warning', async () => { + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => + HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }), + ), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i })) + + await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument()) + expect(screen.getByText(/única vez|solo una vez|this is the only time/i)).toBeInTheDocument() + }) + + it('copy button calls clipboard.writeText with tempPassword', async () => { + const clipboardWriteText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboardWriteText }, + writable: true, + }) + + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => + HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }), + ), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i })) + + await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /copiar|copy/i })) + + expect(clipboardWriteText).toHaveBeenCalledWith('Ax!k9mQ3@rT2') + }) +}) From 2e2d4543ada2f8980d10a697fa061d143f1c3acc Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:12:54 -0300 Subject: [PATCH 13/16] feat(web): router wiring completo + nav link usuarios + MustChangePasswordGate integration [UDT-008] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agrega ProtectedPage helper que combina ProtectedRoute + MustChangePasswordGate + ProtectedLayout - Rutas nuevas: /usuarios, /usuarios/:id, /usuarios/:id/editar con permisos RBAC - /perfil/contrasena sin MustChangePasswordGate (evita redirect loop) - Sidebar: sección "Mi cuenta" con cambio de contraseña; link Usuarios en sección admin --- src/web/src/components/layout/AppSidebar.tsx | 33 ++++++ src/web/src/router.tsx | 111 ++++++++++++++----- 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 7c9330e..c858e9b 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -6,8 +6,10 @@ import { Zap, Settings, UserPlus, + Users, ShieldCheck, KeyRound, + Lock, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -86,6 +88,25 @@ export function SidebarNav() { ) })} + {/* Profile / account section — visible for all authenticated users */} +
+ + Mi cuenta + +
+ + + Cambiar contraseña + + {/* Admin-only section */} {isAdmin && ( <> @@ -94,6 +115,18 @@ export function SidebarNav() { Administración + + + Usuarios + {children} } +/** + * Wraps a protected route with ProtectedLayout + MustChangePasswordGate. + * The gate forces users with mustChangePassword=true to /perfil/contrasena. + */ +function ProtectedPage({ + children, + requiredPermissions, +}: { + children: React.ReactNode + requiredPermissions?: string[] +}) { + return ( + + + {children} + + + ) +} + export function AppRoutes() { return ( + {/* Public routes */} } /> + + {/* Change password — protected but NO MustChangePasswordGate (avoids redirect loop) */} - + } /> + + {/* Protected routes — all wrapped with MustChangePasswordGate */} + } + /> + + + + + } + /> + - - - - + + + } /> + + + + + } + /> + + + + + } + /> + - - - - + + + } /> + - - - - + + + } /> + - - - - + + + } /> + - - - - + + } /> + } /> ) From 851fed8692d9ea56a7db13e6b444f75c21705552 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 20:54:25 -0300 Subject: [PATCH 14/16] fix(web): cablear ResetPasswordModal en UserDetailPage [UDT-008] El componente ResetPasswordModal estaba implementado pero nunca montado en una pagina. Ahora se renderiza en UserDetailPage, oculto cuando el target es el usuario logueado (evita hit de cannot-self-reset en backend). --- .../features/users/pages/UserDetailPage.tsx | 5 ++ .../features/users/UserDetailPage.test.tsx | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/web/src/tests/features/users/UserDetailPage.test.tsx diff --git a/src/web/src/features/users/pages/UserDetailPage.tsx b/src/web/src/features/users/pages/UserDetailPage.tsx index 2e2cbff..3c48b2f 100644 --- a/src/web/src/features/users/pages/UserDetailPage.tsx +++ b/src/web/src/features/users/pages/UserDetailPage.tsx @@ -4,11 +4,14 @@ import { Badge } from '@/components/ui/badge' import { useUser } from '../hooks/useUser' import { useDeactivateUser } from '../hooks/useDeactivateUser' import { useReactivateUser } from '../hooks/useReactivateUser' +import { ResetPasswordModal } from '../components/ResetPasswordModal' +import { useAuthStore } from '@/stores/authStore' export function UserDetailPage() { const { id } = useParams<{ id: string }>() const userId = Number(id) const navigate = useNavigate() + const loggedUserId = useAuthStore((s) => s.user?.id) const { data: user, isLoading } = useUser(userId) const { mutate: deactivate, isPending: deactivating } = useDeactivateUser() @@ -90,6 +93,8 @@ export function UserDetailPage() { {reactivating ? 'Reactivando...' : 'Reactivar'} )} + + {loggedUserId !== userId && } ) diff --git a/src/web/src/tests/features/users/UserDetailPage.test.tsx b/src/web/src/tests/features/users/UserDetailPage.test.tsx new file mode 100644 index 0000000..4936b8c --- /dev/null +++ b/src/web/src/tests/features/users/UserDetailPage.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { UserDetailPage } from '../../../features/users/pages/UserDetailPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +const target = { + id: 5, + username: 'cajero1', + nombre: 'Juan', + apellido: 'Perez', + email: 'juan@test.com', + rol: 'cajero', + activo: true, + permisosJson: '[]', + fechaModificacion: null, + ultimoLogin: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() +}) +afterAll(() => server.close()) + +function renderDetail(userId: number) { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + + + , + ) +} + +describe('UserDetailPage — reset password wiring', () => { + it('shows "Resetear contraseña" button when viewing another user', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(target)), + ) + + renderDetail(5) + + await waitFor(() => expect(screen.getByText('Juan Perez')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /resetear contraseña/i })).toBeInTheDocument() + }) + + it('hides "Resetear contraseña" button when viewing own profile (prevent cannot-self-reset)', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/1`, () => + HttpResponse.json({ ...target, id: 1, username: 'admin', nombre: 'Admin', apellido: 'Root', rol: 'admin' }), + ), + ) + + renderDetail(1) + + await waitFor(() => expect(screen.getByText('Admin Root')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument() + }) +}) From 9e93c70d8b2e9e04bac14e5fe72b7b9198bcbdf7 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 20:55:26 -0300 Subject: [PATCH 15/16] =?UTF-8?q?refactor(web):=20mover=20Cambiar=20contra?= =?UTF-8?q?se=C3=B1a=20de=20sidebar=20a=20menu=20perfil=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La seccion Mi cuenta en el sidebar quedaba desprolija con un unico item. Se movio Cambiar contraseña al dropdown del avatar en AppHeader donde pertenece semanticamente. --- src/web/src/components/layout/AppHeader.tsx | 6 +++++- src/web/src/components/layout/AppSidebar.tsx | 20 -------------------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/web/src/components/layout/AppHeader.tsx b/src/web/src/components/layout/AppHeader.tsx index 4d868b9..77b27d3 100644 --- a/src/web/src/components/layout/AppHeader.tsx +++ b/src/web/src/components/layout/AppHeader.tsx @@ -1,4 +1,4 @@ -import { Menu, LogOut, User } from 'lucide-react' +import { Menu, LogOut, User, Lock } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { Sheet, @@ -85,6 +85,10 @@ export function AppHeader() { Mi perfil + void navigate('/perfil/contrasena')}> + + Cambiar contraseña + diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index c858e9b..a46ac66 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -9,7 +9,6 @@ import { Users, ShieldCheck, KeyRound, - Lock, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -88,25 +87,6 @@ export function SidebarNav() { ) })} - {/* Profile / account section — visible for all authenticated users */} -
- - Mi cuenta - -
- - - Cambiar contraseña - - {/* Admin-only section */} {isAdmin && ( <> From 06908263f6f4848bcf7c4ced9b30110c6dabdaeb Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 21:00:08 -0300 Subject: [PATCH 16/16] fix(web): cablear ResetPasswordModal en UserEditPage [UDT-008] El row click de UsersListPage navega directo a /usuarios/:id/editar, por lo que el modal montado solo en UserDetailPage no era alcanzable desde el flujo real. Ahora tambien esta en el header del EditPage, al lado del boton Volver, oculto cuando el target es el user logueado. --- .../src/features/users/pages/UserEditPage.tsx | 12 +++++++--- .../features/users/UserEditPage.test.tsx | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx index bc4b7c6..8ac581d 100644 --- a/src/web/src/features/users/pages/UserEditPage.tsx +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -18,6 +18,8 @@ import { } from '@/components/ui/form' import { useUser } from '../hooks/useUser' import { useUpdateUser } from '../hooks/useUpdateUser' +import { ResetPasswordModal } from '../components/ResetPasswordModal' +import { useAuthStore } from '@/stores/authStore' const editSchema = z.object({ nombre: z.string().min(1, 'El nombre es requerido'), @@ -45,6 +47,7 @@ export function UserEditPage() { const { id } = useParams<{ id: string }>() const userId = Number(id) const navigate = useNavigate() + const loggedUserId = useAuthStore((s) => s.user?.id) const { data: user, isLoading } = useUser(userId) const { mutate, isPending, error } = useUpdateUser(userId) @@ -112,9 +115,12 @@ export function UserEditPage() {

Editar Usuario

- +
+ {loggedUserId !== userId && } + +
{/* Username — display only, not editable */} diff --git a/src/web/src/tests/features/users/UserEditPage.test.tsx b/src/web/src/tests/features/users/UserEditPage.test.tsx index b13c183..5b8882c 100644 --- a/src/web/src/tests/features/users/UserEditPage.test.tsx +++ b/src/web/src/tests/features/users/UserEditPage.test.tsx @@ -138,4 +138,28 @@ describe('UserEditPage', () => { // Should NOT navigate expect(mockNavigate).not.toHaveBeenCalled() }) + + it('shows "Resetear contraseña" button when editing another user', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)), + ) + + renderEditPage(5) + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /resetear contraseña/i })).toBeInTheDocument() + }) + + it('hides "Resetear contraseña" button when editing own profile', async () => { + server.use( + http.get(`${API_URL}/api/v1/users/1`, () => + HttpResponse.json({ ...mockUserDetail, id: 1, username: 'admin' }), + ), + ) + + renderEditPage(1) + + await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument() + }) })