From 292533678393225a51bd8349eecabc1f4608fd7c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 17:46:23 -0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20List=20+=20GetById=20usuarios=20?= =?UTF-8?q?=E2=80=94=20handlers,=20repo,=20endpoints=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/UsuariosController.cs | 175 +++++++++++++++++- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 50 +++++ .../Common/TempPasswordGenerator.cs | 43 +++++ src/api/SIGCM2.Application/Common/Unit.cs | 7 + .../SIGCM2.Application/DependencyInjection.cs | 17 ++ .../ChangeMyPasswordCommand.cs | 7 + .../ChangeMyPasswordCommandHandler.cs | 37 ++++ .../ChangeMyPasswordCommandValidator.cs | 34 ++++ .../Deactivate/DeactivateUsuarioCommand.cs | 3 + .../DeactivateUsuarioCommandHandler.cs | 54 ++++++ .../Usuarios/GetById/GetUsuarioByIdQuery.cs | 3 + .../GetById/GetUsuarioByIdQueryHandler.cs | 34 ++++ .../Usuarios/GetById/UsuarioDetailDto.cs | 17 ++ .../Usuarios/List/ListUsuariosQuery.cs | 9 + .../Usuarios/List/ListUsuariosQueryHandler.cs | 31 ++++ .../Usuarios/List/UsuarioListItemDto.cs | 12 ++ .../Reactivate/ReactivateUsuarioCommand.cs | 3 + .../ReactivateUsuarioCommandHandler.cs | 45 +++++ .../ResetUsuarioPasswordCommand.cs | 3 + .../ResetUsuarioPasswordCommandHandler.cs | 44 +++++ .../ResetUsuarioPasswordResponse.cs | 6 + .../Usuarios/Update/UpdateUsuarioCommand.cs | 10 + .../Update/UpdateUsuarioCommandHandler.cs | 70 +++++++ .../Update/UpdateUsuarioCommandValidator.cs | 29 +++ .../Exceptions/InvalidOldPasswordException.cs | 10 + .../Usuarios/GetUsuarioByIdEndpointTests.cs | 131 +++++++++++++ .../Usuarios/ListUsuariosEndpointTests.cs | 152 +++++++++++++++ .../GetUsuarioByIdQueryHandlerTests.cs | 61 ++++++ .../Usuarios/ListUsuariosQueryHandlerTests.cs | 119 ++++++++++++ 29 files changed, 1210 insertions(+), 6 deletions(-) create mode 100644 src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs create mode 100644 src/api/SIGCM2.Application/Common/Unit.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index f9279b7..89184c6 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -3,26 +3,42 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.Create; +using SIGCM2.Application.Usuarios.Deactivate; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Application.Usuarios.List; +using SIGCM2.Application.Usuarios.Reactivate; +using SIGCM2.Application.Usuarios.Update; +using SIGCM2.Application.Usuarios.ChangeMyPassword; +using SIGCM2.Application.Usuarios.ResetPassword; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; namespace SIGCM2.Api.Controllers; +/// +/// UDT-001/UDT-008: Usuario management endpoints. +/// RequirePermission moved to method level to allow /me/password with [Authorize] only. +/// [ApiController] [Route("api/v1/users")] -[RequirePermission("administracion:usuarios:gestionar")] public sealed class UsuariosController : ControllerBase { private readonly IDispatcher _dispatcher; - private readonly IValidator _validator; + private readonly IValidator _createValidator; - public UsuariosController(IDispatcher dispatcher, IValidator validator) + public UsuariosController( + IDispatcher dispatcher, + IValidator createValidator) { _dispatcher = dispatcher; - _validator = validator; + _createValidator = createValidator; } - /// Creates a new user. Requires admin role. + /// Creates a new user. Requires administracion:usuarios:gestionar. [HttpPost] + [RequirePermission("administracion:usuarios:gestionar")] [ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -38,7 +54,7 @@ public sealed class UsuariosController : ControllerBase Email: request.Email, Rol: request.Rol ?? string.Empty); - var validation = await _validator.ValidateAsync(command); + var validation = await _createValidator.ValidateAsync(command); if (!validation.IsValid) { var errors = validation.Errors @@ -51,8 +67,144 @@ public sealed class UsuariosController : ControllerBase return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result); } + + /// Lists usuarios with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListUsuarios( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? rol = null, + [FromQuery] bool? activo = null, + [FromQuery] string? search = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListUsuariosQuery(page, pageSize, rol, activo, search); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single usuario by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUsuarioById([FromRoute] int id) + { + var query = new GetUsuarioByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a usuario's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request) + { + var command = new UpdateUsuarioCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Apellido: request.Apellido ?? string.Empty, + Email: request.Email, + Rol: request.Rol ?? string.Empty, + Activo: request.Activo ?? true); + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a usuario (idempotent). + [HttpPatch("{id:int}/deactivate")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateUsuario([FromRoute] int id) + { + var command = new DeactivateUsuarioCommand(id); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Reactivates a usuario (idempotent). + [HttpPatch("{id:int}/reactivate")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateUsuario([FromRoute] int id) + { + var command = new ReactivateUsuarioCommand(id); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// + /// Changes the authenticated user's own password. + /// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it). + /// Requires only authentication (no specific permission). + /// + [HttpPut("me/password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ChangeMyPassword([FromBody] ChangeMyPasswordRequest request) + { + var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new UnauthorizedAccessException(); + + var command = new ChangeMyPasswordCommand( + UsuarioId: int.Parse(sub), + OldPassword: request.OldPassword ?? string.Empty, + NewPassword: request.NewPassword ?? string.Empty); + + await _dispatcher.Send(command); + return NoContent(); + } + + /// Resets a usuario's password (admin only). Returns a one-time temp password. + [HttpPost("{id:int}/password/reset")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ResetUsuarioPassword([FromRoute] int id) + { + var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new UnauthorizedAccessException(); + + var command = new ResetUsuarioPasswordCommand( + TargetId: id, + CallerId: int.Parse(sub)); + + var result = await _dispatcher.Send(command); + return Ok(result); + } } +// ── request body records ────────────────────────────────────────────────────── + /// Create user request body — nullable to catch missing field scenarios. public sealed record CreateUsuarioRequest( string? Username, @@ -61,3 +213,14 @@ public sealed record CreateUsuarioRequest( string? Apellido, string? Email, string? Rol); + +public sealed record UpdateUsuarioRequest( + string? Nombre, + string? Apellido, + string? Email, + string? Rol, + bool? Activo); + +public sealed record ChangeMyPasswordRequest( + string? OldPassword, + string? NewPassword); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index e1911b8..f583aed 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -19,6 +19,56 @@ public sealed class ExceptionFilter : IExceptionFilter { switch (context.Exception) { + case UsuarioNotFoundException usuarioNotFoundEx: + context.Result = new ObjectResult(new + { + error = "usuario_not_found", + message = usuarioNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case LastAdminLockoutException: + context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Type = "about:blank", + Title = "last-admin-lockout", + Status = 400, + Detail = "No se puede desactivar o cambiar el rol del último administrador activo." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case CannotSelfResetException: + context.Result = new ObjectResult(new + { + error = "cannot-self-reset", + message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case InvalidOldPasswordException: + context.Result = new ObjectResult(new + { + error = "invalid-old-password", + message = "La contraseña actual es incorrecta." + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + case UsernameAlreadyExistsException usernameEx: context.Result = new ObjectResult(new { diff --git a/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs b/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs new file mode 100644 index 0000000..2998f28 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SIGCM2.Application.Common; + +/// +/// Generates cryptographically secure temporary passwords. +/// Excludes visually ambiguous characters (I, O, l, o, 0, 1). +/// +public static class TempPasswordGenerator +{ + private const string UpperChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O + private const string LowerChars = "abcdefghijkmnpqrstuvwxyz"; // no l, o + private const string DigitChars = "23456789"; // no 0, 1 + private const string SymbolChars = "!@#$%&*+-=?"; // copy-paste safe + + private const string Charset = UpperChars + LowerChars + DigitChars + SymbolChars; + + public static string Generate(int length = 12) + { + if (length < 8) + throw new ArgumentOutOfRangeException(nameof(length), "Password length must be at least 8."); + + // SECURITY: NEVER log the result of this method + Span bytes = stackalloc byte[length]; + RandomNumberGenerator.Fill(bytes); + + var sb = new StringBuilder(length); + for (int i = 0; i < length; i++) + sb.Append(Charset[bytes[i] % Charset.Length]); + + var result = sb.ToString(); + + // Guarantee diversity: at least 1 upper, 1 lower, 1 digit, 1 symbol + return HasDiversity(result) ? result : Generate(length); + } + + private static bool HasDiversity(string pwd) + => pwd.Any(c => UpperChars.Contains(c)) + && pwd.Any(c => LowerChars.Contains(c)) + && pwd.Any(c => DigitChars.Contains(c)) + && pwd.Any(c => SymbolChars.Contains(c)); +} diff --git a/src/api/SIGCM2.Application/Common/Unit.cs b/src/api/SIGCM2.Application/Common/Unit.cs new file mode 100644 index 0000000..2ca3b64 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/Unit.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Common; + +/// Represents the absence of a meaningful return value. +public readonly struct Unit +{ + public static readonly Unit Value = default; +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index c864f1a..1476873 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,7 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Common; using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.GetByRol; @@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos; using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.Update; +using SIGCM2.Application.Usuarios.ChangeMyPassword; using SIGCM2.Application.Usuarios.Create; +using SIGCM2.Application.Usuarios.Deactivate; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Application.Usuarios.List; +using SIGCM2.Application.Usuarios.Reactivate; +using SIGCM2.Application.Usuarios.ResetPassword; +using SIGCM2.Application.Usuarios.Update; namespace SIGCM2.Application; @@ -40,6 +48,15 @@ public static class DependencyInjection services.AddScoped>, GetRolPermisosQueryHandler>(); services.AddScoped>, AssignPermisosToRolCommandHandler>(); + // Usuarios (UDT-008) + services.AddScoped>, ListUsuariosQueryHandler>(); + services.AddScoped, GetUsuarioByIdQueryHandler>(); + services.AddScoped, UpdateUsuarioCommandHandler>(); + services.AddScoped, DeactivateUsuarioCommandHandler>(); + services.AddScoped, ReactivateUsuarioCommandHandler>(); + services.AddScoped, ChangeMyPasswordCommandHandler>(); + services.AddScoped, ResetUsuarioPasswordCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs new file mode 100644 index 0000000..9182179 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed record ChangeMyPasswordCommand( + int UsuarioId, + string OldPassword, + string NewPassword +); diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs new file mode 100644 index 0000000..60df389 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs @@ -0,0 +1,37 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed class ChangeMyPasswordCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IPasswordHasher _hasher; + + public ChangeMyPasswordCommandHandler( + IUsuarioRepository repository, + IPasswordHasher hasher) + { + _repository = repository; + _hasher = hasher; + } + + public async Task Handle(ChangeMyPasswordCommand cmd) + { + var user = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + if (!_hasher.Verify(cmd.OldPassword, user.PasswordHash)) + throw new InvalidOldPasswordException(); + + var newHash = _hasher.Hash(cmd.NewPassword); + await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false); + + // TODO: audit — defer to ADM-004 + // NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05) + return Unit.Value; + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs new file mode 100644 index 0000000..44c8c6e --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using SIGCM2.Application.Auth; + +namespace SIGCM2.Application.Usuarios.ChangeMyPassword; + +public sealed class ChangeMyPasswordCommandValidator : AbstractValidator +{ + public ChangeMyPasswordCommandValidator(AuthOptions authOptions) + { + RuleFor(x => x.OldPassword) + .NotEmpty() + .WithMessage("La contraseña actual es requerida."); + + RuleFor(x => x.NewPassword) + .NotEmpty() + .WithMessage("La nueva contraseña es requerida.") + .MinimumLength(authOptions.PasswordMinLength) + .WithMessage($"La contraseña debe tener al menos {authOptions.PasswordMinLength} caracteres."); + + if (authOptions.PasswordRequireLetter) + { + RuleFor(x => x.NewPassword) + .Matches(@"[a-zA-Z]") + .WithMessage("La contraseña debe contener al menos una letra."); + } + + if (authOptions.PasswordRequireDigit) + { + RuleFor(x => x.NewPassword) + .Matches(@"\d") + .WithMessage("La contraseña debe contener al menos un dígito."); + } + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs new file mode 100644 index 0000000..d48fd3e --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.Deactivate; + +public sealed record DeactivateUsuarioCommand(int UsuarioId); diff --git a/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs new file mode 100644 index 0000000..48b1449 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Deactivate/DeactivateUsuarioCommandHandler.cs @@ -0,0 +1,54 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Deactivate; + +public sealed class DeactivateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public DeactivateUsuarioCommandHandler( + IUsuarioRepository repository, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(DeactivateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + // Idempotent: already inactive → return as-is without touching FechaModificacion + if (!target.Activo) + { + return new UsuarioDetailDto( + target.Id, target.Username, target.Nombre, target.Apellido, + target.Email, target.Rol, target.Activo, target.MustChangePassword, + target.UltimoLogin, target.FechaModificacion); + } + + // Guard: anti-lockout + if (target.Rol == "admin" && await _repository.CountActiveAdminsAsync() <= 1) + throw new LastAdminLockoutException(); + + var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.UsuarioId, fields, now); + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now); + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs new file mode 100644 index 0000000..7d2c1fa --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.GetById; + +public sealed record GetUsuarioByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs new file mode 100644 index 0000000..01baf7d --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/GetUsuarioByIdQueryHandler.cs @@ -0,0 +1,34 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.GetById; + +public sealed class GetUsuarioByIdQueryHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + + public GetUsuarioByIdQueryHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetUsuarioByIdQuery query) + { + var usuario = await _repository.GetDetailAsync(query.Id) + ?? throw new UsuarioNotFoundException(query.Id); + + return new UsuarioDetailDto( + Id: usuario.Id, + Username: usuario.Username, + Nombre: usuario.Nombre, + Apellido: usuario.Apellido, + Email: usuario.Email, + Rol: usuario.Rol, + Activo: usuario.Activo, + MustChangePassword: usuario.MustChangePassword, + UltimoLogin: usuario.UltimoLogin, + FechaModificacion: usuario.FechaModificacion + ); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs b/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs new file mode 100644 index 0000000..f6b1451 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/GetById/UsuarioDetailDto.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Application.Usuarios.GetById; + +/// +/// Full detail projection — excludes PasswordHash and PermisosJson (security). +/// +public sealed record UsuarioDetailDto( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + bool MustChangePassword, + DateTime? UltimoLogin, + DateTime? FechaModificacion +); diff --git a/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs new file mode 100644 index 0000000..ec0cc09 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Usuarios.List; + +public sealed record ListUsuariosQuery( + int Page, + int PageSize, + string? Rol, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs new file mode 100644 index 0000000..bbc6124 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/ListUsuariosQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Usuarios.List; + +public sealed class ListUsuariosQueryHandler : ICommandHandler> +{ + private readonly IUsuarioRepository _repository; + + public ListUsuariosQueryHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListUsuariosQuery query) + { + // Clamp paging params + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new UsuariosQuery(page, pageSize, query.Rol, query.Activo, query.Search); + var paged = await _repository.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(u => new UsuarioListItemDto(u.Id, u.Username, u.Nombre, u.Apellido, u.Email, u.Rol, u.Activo, u.UltimoLogin)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs b/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs new file mode 100644 index 0000000..56bb3ed --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/List/UsuarioListItemDto.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Application.Usuarios.List; + +public sealed record UsuarioListItemDto( + int Id, + string Username, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo, + DateTime? UltimoLogin +); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs new file mode 100644 index 0000000..5a9e503 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.Reactivate; + +public sealed record ReactivateUsuarioCommand(int UsuarioId); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs new file mode 100644 index 0000000..3c56919 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs @@ -0,0 +1,45 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Reactivate; + +public sealed class ReactivateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + + public ReactivateUsuarioCommandHandler(IUsuarioRepository repository) + { + _repository = repository; + } + + public async Task Handle(ReactivateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + // Idempotent: already active → return as-is without touching FechaModificacion + if (target.Activo) + { + return new UsuarioDetailDto( + target.Id, target.Username, target.Nombre, target.Apellido, + target.Email, target.Rol, target.Activo, target.MustChangePassword, + target.UltimoLogin, target.FechaModificacion); + } + + var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.UsuarioId, fields, now); + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.UsuarioId) + ?? throw new UsuarioNotFoundException(cmd.UsuarioId); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs new file mode 100644 index 0000000..023deb2 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId); diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs new file mode 100644 index 0000000..502ef98 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordCommandHandler.cs @@ -0,0 +1,44 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IPasswordHasher _hasher; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public ResetUsuarioPasswordCommandHandler( + IUsuarioRepository repository, + IPasswordHasher hasher, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _hasher = hasher; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(ResetUsuarioPasswordCommand cmd) + { + // Cannot self-reset: admin must use /me/password + if (cmd.CallerId == cmd.TargetId) + throw new CannotSelfResetException(); + + var target = await _repository.GetByIdAsync(cmd.TargetId) + ?? throw new UsuarioNotFoundException(cmd.TargetId); + + var temp = TempPasswordGenerator.Generate(12); + // SECURITY: NEVER log tempPassword + var hash = _hasher.Hash(temp); + + await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true); + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow); + + // TODO: audit — defer to ADM-004 + return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs new file mode 100644 index 0000000..83007a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/ResetPassword/ResetUsuarioPasswordResponse.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Usuarios.ResetPassword; + +public sealed record ResetUsuarioPasswordResponse( + string TempPassword, + bool MustChangeOnLogin +); diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs new file mode 100644 index 0000000..707c161 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Usuarios.Update; + +public sealed record UpdateUsuarioCommand( + int Id, + string Nombre, + string Apellido, + string? Email, + string Rol, + bool Activo +); diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs new file mode 100644 index 0000000..02c5370 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandHandler.cs @@ -0,0 +1,70 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Update; + +public sealed class UpdateUsuarioCommandHandler : ICommandHandler +{ + private readonly IUsuarioRepository _repository; + private readonly IRolRepository _rolRepository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public UpdateUsuarioCommandHandler( + IUsuarioRepository repository, + IRolRepository rolRepository, + IRefreshTokenRepository refreshTokenRepository) + { + _repository = repository; + _rolRepository = rolRepository; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task Handle(UpdateUsuarioCommand cmd) + { + var target = await _repository.GetByIdAsync(cmd.Id) + ?? throw new UsuarioNotFoundException(cmd.Id); + + // Guard: validate rol exists and is active + var rolExists = await _rolRepository.ExistsActiveByCodigoAsync(cmd.Rol); + if (!rolExists) + throw new FluentValidation.ValidationException( + [new FluentValidation.Results.ValidationFailure("Rol", $"El rol '{cmd.Rol}' no existe o está inactivo.")]); + + // Guard: anti-lockout — cannot remove last active admin + if (target.Rol == "admin" && target.Activo) + { + var isChangingRol = !string.Equals(cmd.Rol, "admin", StringComparison.Ordinal); + var isDeactivating = !cmd.Activo; + + if ((isChangingRol || isDeactivating) + && await _repository.CountActiveAdminsAsync() <= 1) + { + throw new LastAdminLockoutException(); + } + } + + var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo); + var now = DateTime.UtcNow; + await _repository.UpdateAsync(cmd.Id, fields, now); + + // Revoke refresh tokens if rol changed or user deactivated + var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal); + var justDeactivated = target.Activo && !cmd.Activo; + if (rolChanged || justDeactivated) + { + await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now); + } + + // TODO: audit — defer to ADM-004 + var updated = await _repository.GetDetailAsync(cmd.Id) + ?? throw new UsuarioNotFoundException(cmd.Id); + + return new UsuarioDetailDto( + updated.Id, updated.Username, updated.Nombre, updated.Apellido, + updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword, + updated.UltimoLogin, updated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs new file mode 100644 index 0000000..40e315b --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Update/UpdateUsuarioCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Usuarios.Update; + +public sealed class UpdateUsuarioCommandValidator : AbstractValidator +{ + public UpdateUsuarioCommandValidator(IRolRepository rolRepository) + { + RuleFor(x => x.Email) + .EmailAddress() + .When(x => x.Email is not null) + .WithMessage("El formato del email es inválido."); + + RuleFor(x => x.Nombre) + .NotEmpty() + .WithMessage("El nombre es requerido."); + + RuleFor(x => x.Apellido) + .NotEmpty() + .WithMessage("El apellido es requerido."); + + RuleFor(x => x.Rol) + .NotEmpty() + .WithMessage("El rol es requerido.") + .MustAsync(async (rol, ct) => await rolRepository.ExistsActiveByCodigoAsync(rol, ct)) + .WithMessage(x => $"El rol '{x.Rol}' no existe o está inactivo."); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs b/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs new file mode 100644 index 0000000..2a12a08 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/InvalidOldPasswordException.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a user provides an incorrect current password during password change. +/// +public sealed class InvalidOldPasswordException : DomainException +{ + public InvalidOldPasswordException() + : base("La contraseña actual es incorrecta.") { } +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs new file mode 100644 index 0000000..57088c6 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Usuarios; + +/// +/// Integration tests for GET /api/v1/users/{id} (UDT-008 B3). +/// +[Collection("ApiIntegration")] +public sealed class GetUsuarioByIdEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private readonly HttpClient _client; + private readonly SqlTestFixture _db; + + public GetUsuarioByIdEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + _db = new SqlTestFixture(TestConnectionString); + } + + public async Task InitializeAsync() => await _db.InitializeAsync(); + public async Task DisposeAsync() => await _db.DisposeAsync(); + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "admin", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetAdminIdAsync() + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.ExecuteScalarAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + } + + private async Task GetCajeroTokenAsync() + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_getbyid') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('cajero_getbyid', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0) + """); + + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_getbyid", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + [Fact] + public async Task GET_Users_Id_200_Returns_Detail_Shape() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(adminId, json.GetProperty("id").GetInt32()); + Assert.Equal("admin", json.GetProperty("username").GetString()); + Assert.True(json.TryGetProperty("nombre", out _)); + Assert.True(json.TryGetProperty("rol", out _)); + Assert.True(json.TryGetProperty("activo", out _)); + Assert.True(json.TryGetProperty("mustChangePassword", out _)); + } + + [Fact] + public async Task GET_Users_Id_DoesNotContain_PasswordHash_In_Response() + { + var token = await GetAdminTokenAsync(); + var adminId = await GetAdminIdAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rawJson = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("passwordHash", rawJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("permisosJson", rawJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GET_Users_Id_9999_Returns_404() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/9999"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Id_No_Auth_Returns_401() + { + var response = await _client.GetAsync("/api/v1/users/1"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Id_No_Permission_Returns_403() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/1"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs new file mode 100644 index 0000000..8180731 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Usuarios; + +/// +/// Integration tests for GET /api/v1/users (UDT-008 B3). +/// +[Collection("ApiIntegration")] +public sealed class ListUsuariosEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private readonly HttpClient _client; + private readonly SqlTestFixture _db; + + public ListUsuariosEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + _db = new SqlTestFixture(TestConnectionString); + } + + public async Task InitializeAsync() => await _db.InitializeAsync(); + public async Task DisposeAsync() => await _db.DisposeAsync(); + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "admin", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetCajeroTokenAsync() + { + // Seed a cajero user + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_test') + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('cajero_test', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0) + """); + + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", + new { username = "cajero_test", password = "@Diego550@" }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + // ── happy path ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GET_Users_200_Returns_Paged_Shape() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _)); + Assert.True(json.TryGetProperty("page", out _)); + Assert.True(json.TryGetProperty("pageSize", out _)); + Assert.True(json.TryGetProperty("total", out _)); + } + + [Fact] + public async Task GET_Users_Default_PageSize_Is_20() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(20, json.GetProperty("pageSize").GetInt32()); + Assert.Equal(1, json.GetProperty("page").GetInt32()); + } + + [Fact] + public async Task GET_Users_Filter_Rol_Admin_Returns_Only_Admins() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?rol=admin"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + var items = json.GetProperty("items").EnumerateArray().ToList(); + Assert.All(items, item => Assert.Equal("admin", item.GetProperty("rol").GetString())); + } + + [Fact] + public async Task GET_Users_PageSize_0_Returns_400() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?pageSize=0"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GET_Users_Page_0_Returns_400() + { + var token = await GetAdminTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?page=0"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // ── auth ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GET_Users_No_Auth_Returns_401() + { + var response = await _client.GetAsync("/api/v1/users"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GET_Users_No_Permission_Returns_403() + { + var token = await GetCajeroTokenAsync(); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs new file mode 100644 index 0000000..b9a7fe3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/GetUsuarioByIdQueryHandlerTests.cs @@ -0,0 +1,61 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Usuarios.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class GetUsuarioByIdQueryHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly GetUsuarioByIdQueryHandler _handler; + + public GetUsuarioByIdQueryHandlerTests() + { + _handler = new GetUsuarioByIdQueryHandler(_repo); + } + + [Fact] + public async Task Handle_Returns_UsuarioDetailDto_When_Found() + { + var usuario = new Usuario(5, "jperez", "$2a$12$hash", "Juan", "Pérez", "j@x.com", "cajero", "[]", true, + fechaModificacion: null, ultimoLogin: null, mustChangePassword: false); + + _repo.GetDetailAsync(5, Arg.Any()).Returns(usuario); + + var result = await _handler.Handle(new GetUsuarioByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal("jperez", result.Username); + Assert.Equal("Juan", result.Nombre); + Assert.Equal("Pérez", result.Apellido); + Assert.Equal("j@x.com", result.Email); + Assert.Equal("cajero", result.Rol); + Assert.True(result.Activo); + Assert.False(result.MustChangePassword); + } + + [Fact] + public async Task Handle_DoesNotReturn_PasswordHash_In_Dto() + { + var usuario = new Usuario(5, "jperez", "$2a$12$SECRETHASH", "Juan", "Pérez", null, "cajero", "[]", true); + _repo.GetDetailAsync(5, Arg.Any()).Returns(usuario); + + var result = await _handler.Handle(new GetUsuarioByIdQuery(5)); + + // UsuarioDetailDto must not expose PasswordHash + var props = typeof(UsuarioDetailDto).GetProperties().Select(p => p.Name); + Assert.DoesNotContain("PasswordHash", props); + Assert.DoesNotContain("PermisosJson", props); + } + + [Fact] + public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found() + { + _repo.GetDetailAsync(9999, Arg.Any()).Returns((Usuario?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetUsuarioByIdQuery(9999))); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs new file mode 100644 index 0000000..31bdef6 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Usuarios/ListUsuariosQueryHandlerTests.cs @@ -0,0 +1,119 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Usuarios.List; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Usuarios; + +public class ListUsuariosQueryHandlerTests +{ + private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly ListUsuariosQueryHandler _handler; + + public ListUsuariosQueryHandlerTests() + { + _handler = new ListUsuariosQueryHandler(_repo); + } + + [Fact] + public async Task Handle_Returns_PagedResult_With_Items() + { + var items = new List + { + new(1, "admin", "Admin", "Sys", null, "admin", true, null, null) + }; + var paged = new PagedResult(items, 1, 20, 1); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(paged); + + var query = new ListUsuariosQuery(1, 20, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(1, result.Total); + Assert.Single(result.Items); + } + + [Fact] + public async Task Handle_Clamps_PageSize_Above_100_To_100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + var query = new ListUsuariosQuery(1, 200, null, null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_Clamps_Page_Below_1_To_1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(0, 20, null, null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Rol_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, "admin", null, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Rol == "admin"), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Activo_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, null, false, null); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Activo == false), + Arg.Any()); + } + + [Fact] + public async Task Handle_Passes_Search_Filter() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var query = new ListUsuariosQuery(1, 20, null, null, "juan"); + await _handler.Handle(query); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Search == "juan"), + Arg.Any()); + } + + [Fact] + public async Task Handle_Returns_Empty_When_No_Items() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + var result = await _handler.Handle(new ListUsuariosQuery(1, 20, null, null, null)); + + Assert.Equal(0, result.Total); + Assert.Empty(result.Items); + } +}