From 9dcd63543e65ede7b31ace2f61d71cdbc8e4f0d2 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:39:48 -0300 Subject: [PATCH] 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); + } }