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.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index f9279b7..cfa7e61 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -3,26 +3,48 @@ using Microsoft.AspNetCore.Authorization; 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.ResetPassword; +using SIGCM2.Application.Usuarios.Update; +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; + private readonly IValidator _updateValidator; + private readonly IValidator _changePasswordValidator; - public UsuariosController(IDispatcher dispatcher, IValidator validator) + public UsuariosController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator, + IValidator changePasswordValidator) { _dispatcher = dispatcher; - _validator = validator; + _createValidator = createValidator; + _updateValidator = updateValidator; + _changePasswordValidator = changePasswordValidator; } - /// 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 +60,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 +73,162 @@ 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 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); + } + + /// 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); + + 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(); + } + + /// 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 +237,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/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/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/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.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/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/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/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/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/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 7c9330e..a46ac66 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -6,6 +6,7 @@ import { Zap, Settings, UserPlus, + Users, ShieldCheck, KeyRound, } from 'lucide-react' @@ -94,6 +95,18 @@ export function SidebarNav() { Administración + + + Usuarios + 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/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/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/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/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/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/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/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/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/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/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/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/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/UserDetailPage.tsx b/src/web/src/features/users/pages/UserDetailPage.tsx new file mode 100644 index 0000000..3c48b2f --- /dev/null +++ b/src/web/src/features/users/pages/UserDetailPage.tsx @@ -0,0 +1,101 @@ +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' +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() + 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 ? ( + + ) : ( + + )} + + {loggedUserId !== userId && } +
+
+ ) +} 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..8ac581d --- /dev/null +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -0,0 +1,233 @@ +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' +import { ResetPasswordModal } from '../components/ResetPasswordModal' +import { useAuthStore } from '@/stores/authStore' + +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 loggedUserId = useAuthStore((s) => s.user?.id) + + 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

+
+ {loggedUserId !== userId && } + +
+
+ + {/* Username — display only, not editable */} +
+

Usuario

+

{user.username}

+
+ +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Nombre + + + + + + )} + /> + + ( + + Apellido + + + + + + )} + /> + + ( + + Email (opcional) + + + + + + )} + /> + + ( + + Rol + + + + + + )} + /> + + ( + + + + + Activo + + )} + /> + + + + +
+ ) +} 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/router.tsx b/src/web/src/router.tsx index 6c10ee7..9feac6f 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -1,8 +1,13 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAuthStore } from './stores/authStore' import { ProtectedRoute } from './components/routing/ProtectedRoute' +import { MustChangePasswordGate } from './components/routing/MustChangePasswordGate' import { LoginPage } from './features/auth/pages/LoginPage' import { CreateUserPage } from './features/users/pages/CreateUserPage' +import { UsersListPage } from './features/users/pages/UsersListPage' +import { UserDetailPage } from './features/users/pages/UserDetailPage' +import { UserEditPage } from './features/users/pages/UserEditPage' +import { ChangeMyPasswordPage } from './features/profile/pages/ChangeMyPasswordPage' import { RolesPage } from './features/roles/pages/RolesPage' import { NewRolPage } from './features/roles/pages/NewRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage' @@ -19,9 +24,30 @@ function PublicRoute({ children }: { children: React.ReactNode }) { return <>{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 */} + } + /> + + + + + } + /> + - - - - + + + } /> + + + + + } + /> + + + + + } + /> + - - - - + + + } /> + - - - - + + + } /> + - - - - + + + } /> + - - - - + + } /> + } /> ) 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/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') + }) +}) 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() + }) +}) 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..5b8882c --- /dev/null +++ b/src/web/src/tests/features/users/UserEditPage.test.tsx @@ -0,0 +1,165 @@ +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() + }) + + 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() + }) +}) 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/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/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/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' }) + }) +}) 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') + }) +}) 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, 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.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.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.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.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/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); + } } 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/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.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()); + } +} 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/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); + } +} 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))); + } +} 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))); + } +} 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()); + } +} 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);