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);
+ }
+}