feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008]
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-001/UDT-008: Usuario management endpoints.
|
||||
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
public sealed class UsuariosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateUsuarioCommand> _validator;
|
||||
private readonly IValidator<CreateUsuarioCommand> _createValidator;
|
||||
|
||||
public UsuariosController(IDispatcher dispatcher, IValidator<CreateUsuarioCommand> validator)
|
||||
public UsuariosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateUsuarioCommand> createValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_validator = validator;
|
||||
_createValidator = createValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new user. Requires admin role.</summary>
|
||||
/// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Lists usuarios with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<UsuarioListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> 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<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single usuario by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetUsuarioById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetUsuarioByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetUsuarioByIdQuery, UsuarioDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a usuario's editable fields.</summary>
|
||||
[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<IActionResult> 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<UpdateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a usuario (idempotent).</summary>
|
||||
[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<IActionResult> DeactivateUsuario([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateUsuarioCommand(id);
|
||||
var result = await _dispatcher.Send<DeactivateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a usuario (idempotent).</summary>
|
||||
[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<IActionResult> ReactivateUsuario([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateUsuarioCommand(id);
|
||||
var result = await _dispatcher.Send<ReactivateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[HttpPut("me/password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> 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<ChangeMyPasswordCommand, Unit>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Resets a usuario's password (admin only). Returns a one-time temp password.</summary>
|
||||
[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<IActionResult> 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<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
43
src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs
Normal file
43
src/api/SIGCM2.Application/Common/TempPasswordGenerator.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Generates cryptographically secure temporary passwords.
|
||||
/// Excludes visually ambiguous characters (I, O, l, o, 0, 1).
|
||||
/// </summary>
|
||||
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<byte> 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));
|
||||
}
|
||||
7
src/api/SIGCM2.Application/Common/Unit.cs
Normal file
7
src/api/SIGCM2.Application/Common/Unit.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>Represents the absence of a meaningful return value.</summary>
|
||||
public readonly struct Unit
|
||||
{
|
||||
public static readonly Unit Value = default;
|
||||
}
|
||||
@@ -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<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
|
||||
|
||||
// Usuarios (UDT-008)
|
||||
services.AddScoped<ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>, ListUsuariosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>, GetUsuarioByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>, UpdateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>, DeactivateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>, ReactivateUsuarioCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
|
||||
public sealed record ChangeMyPasswordCommand(
|
||||
int UsuarioId,
|
||||
string OldPassword,
|
||||
string NewPassword
|
||||
);
|
||||
@@ -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<ChangeMyPasswordCommand, Unit>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
|
||||
public ChangeMyPasswordCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
}
|
||||
|
||||
public async Task<Unit> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Auth;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
|
||||
public sealed class ChangeMyPasswordCommandValidator : AbstractValidator<ChangeMyPasswordCommand>
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.Deactivate;
|
||||
|
||||
public sealed record DeactivateUsuarioCommand(int UsuarioId);
|
||||
@@ -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<DeactivateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public DeactivateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_repository = repository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.GetById;
|
||||
|
||||
public sealed record GetUsuarioByIdQuery(int Id);
|
||||
@@ -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<GetUsuarioByIdQuery, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public GetUsuarioByIdQueryHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Application.Usuarios.GetById;
|
||||
|
||||
/// <summary>
|
||||
/// Full detail projection — excludes PasswordHash and PermisosJson (security).
|
||||
/// </summary>
|
||||
public sealed record UsuarioDetailDto(
|
||||
int Id,
|
||||
string Username,
|
||||
string Nombre,
|
||||
string Apellido,
|
||||
string? Email,
|
||||
string Rol,
|
||||
bool Activo,
|
||||
bool MustChangePassword,
|
||||
DateTime? UltimoLogin,
|
||||
DateTime? FechaModificacion
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Usuarios.List;
|
||||
|
||||
public sealed record ListUsuariosQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Rol,
|
||||
bool? Activo,
|
||||
string? Search
|
||||
);
|
||||
@@ -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<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public ListUsuariosQueryHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<UsuarioListItemDto>> 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<UsuarioListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.Reactivate;
|
||||
|
||||
public sealed record ReactivateUsuarioCommand(int UsuarioId);
|
||||
@@ -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<ReactivateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
|
||||
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Usuarios.ResetPassword;
|
||||
|
||||
public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId);
|
||||
@@ -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<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>
|
||||
{
|
||||
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<ResetUsuarioPasswordResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Usuarios.ResetPassword;
|
||||
|
||||
public sealed record ResetUsuarioPasswordResponse(
|
||||
string TempPassword,
|
||||
bool MustChangeOnLogin
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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<UpdateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
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<UsuarioDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Update;
|
||||
|
||||
public sealed class UpdateUsuarioCommandValidator : AbstractValidator<UpdateUsuarioCommand>
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a user provides an incorrect current password during password change.
|
||||
/// </summary>
|
||||
public sealed class InvalidOldPasswordException : DomainException
|
||||
{
|
||||
public InvalidOldPasswordException()
|
||||
: base("La contraseña actual es incorrecta.") { }
|
||||
}
|
||||
131
tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs
Normal file
131
tests/SIGCM2.Api.Tests/Usuarios/GetUsuarioByIdEndpointTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for GET /api/v1/users/{id} (UDT-008 B3).
|
||||
/// </summary>
|
||||
[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<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new { username = "admin", password = "@Diego550@" });
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<int> GetAdminIdAsync()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.ExecuteScalarAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
|
||||
}
|
||||
|
||||
private async Task<string> 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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
152
tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs
Normal file
152
tests/SIGCM2.Api.Tests/Usuarios/ListUsuariosEndpointTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for GET /api/v1/users (UDT-008 B3).
|
||||
/// </summary>
|
||||
[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<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new { username = "admin", password = "@Diego550@" });
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> 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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<IUsuarioRepository>();
|
||||
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<CancellationToken>()).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<CancellationToken>()).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<CancellationToken>()).Returns((Usuario?)null);
|
||||
|
||||
await Assert.ThrowsAsync<UsuarioNotFoundException>(
|
||||
() => _handler.Handle(new GetUsuarioByIdQuery(9999)));
|
||||
}
|
||||
}
|
||||
@@ -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<IUsuarioRepository>();
|
||||
private readonly ListUsuariosQueryHandler _handler;
|
||||
|
||||
public ListUsuariosQueryHandlerTests()
|
||||
{
|
||||
_handler = new ListUsuariosQueryHandler(_repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Returns_PagedResult_With_Items()
|
||||
{
|
||||
var items = new List<UsuarioListItem>
|
||||
{
|
||||
new(1, "admin", "Admin", "Sys", null, "admin", true, null, null)
|
||||
};
|
||||
var paged = new PagedResult<UsuarioListItem>(items, 1, 20, 1);
|
||||
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.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<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 100, 0));
|
||||
|
||||
var query = new ListUsuariosQuery(1, 200, null, null, null);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<UsuariosQuery>(q => q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Clamps_Page_Below_1_To_1()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
|
||||
|
||||
var query = new ListUsuariosQuery(0, 20, null, null, null);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<UsuariosQuery>(q => q.Page == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Passes_Rol_Filter()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
|
||||
|
||||
var query = new ListUsuariosQuery(1, 20, "admin", null, null);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<UsuariosQuery>(q => q.Rol == "admin"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Passes_Activo_Filter()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
|
||||
|
||||
var query = new ListUsuariosQuery(1, 20, null, false, null);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<UsuariosQuery>(q => q.Activo == false),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Passes_Search_Filter()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
|
||||
|
||||
var query = new ListUsuariosQuery(1, 20, null, null, "juan");
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<UsuariosQuery>(q => q.Search == "juan"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Returns_Empty_When_No_Items()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
|
||||
|
||||
var result = await _handler.Handle(new ListUsuariosQuery(1, 20, null, null, null));
|
||||
|
||||
Assert.Equal(0, result.Total);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user