Compare commits
17 Commits
c0d1ea4ac2
...
68897f446b
| Author | SHA1 | Date | |
|---|---|---|---|
| 68897f446b | |||
| 06908263f6 | |||
| 9e93c70d8b | |||
| 851fed8692 | |||
| 2e2d4543ad | |||
| 25ed0f6452 | |||
| 64e0a8b5fb | |||
| 9512f4125d | |||
| d998d215e0 | |||
| 7d96d5ff18 | |||
| a3bd066f7b | |||
| 473566f255 | |||
| 14c385fdb1 | |||
| 2925336783 | |||
| 9dcd63543e | |||
| d1f7b3805b | |||
| 5ddc5ddf02 |
@@ -0,0 +1,34 @@
|
|||||||
|
-- V008: Add MustChangePassword column + IX_Usuario_Activo_Rol index
|
||||||
|
-- Idempotent: re-runnable without errors.
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Add MustChangePassword column (idempotent via COL_LENGTH check)
|
||||||
|
IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Usuario
|
||||||
|
ADD MustChangePassword BIT NOT NULL
|
||||||
|
CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0);
|
||||||
|
PRINT 'Column MustChangePassword added to dbo.Usuario.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Column MustChangePassword already exists — skipping.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Compound index for listado filtrado (Activo + Rol) and anti-lockout guard
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_Usuario_Activo_Rol'
|
||||||
|
AND object_id = OBJECT_ID('dbo.Usuario')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Usuario_Activo_Rol
|
||||||
|
ON dbo.Usuario(Activo, Rol)
|
||||||
|
INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion);
|
||||||
|
PRINT 'Index IX_Usuario_Activo_Rol created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Index IX_Usuario_Activo_Rol already exists — skipping.';
|
||||||
|
GO
|
||||||
@@ -3,26 +3,48 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
|
using SIGCM2.Application.Usuarios.Deactivate;
|
||||||
|
using SIGCM2.Application.Usuarios.GetById;
|
||||||
|
using SIGCM2.Application.Usuarios.List;
|
||||||
|
using SIGCM2.Application.Usuarios.Reactivate;
|
||||||
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Controllers;
|
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]
|
[ApiController]
|
||||||
[Route("api/v1/users")]
|
[Route("api/v1/users")]
|
||||||
[RequirePermission("administracion:usuarios:gestionar")]
|
|
||||||
public sealed class UsuariosController : ControllerBase
|
public sealed class UsuariosController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDispatcher _dispatcher;
|
private readonly IDispatcher _dispatcher;
|
||||||
private readonly IValidator<CreateUsuarioCommand> _validator;
|
private readonly IValidator<CreateUsuarioCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateUsuarioCommand> _updateValidator;
|
||||||
|
private readonly IValidator<ChangeMyPasswordCommand> _changePasswordValidator;
|
||||||
|
|
||||||
public UsuariosController(IDispatcher dispatcher, IValidator<CreateUsuarioCommand> validator)
|
public UsuariosController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateUsuarioCommand> createValidator,
|
||||||
|
IValidator<UpdateUsuarioCommand> updateValidator,
|
||||||
|
IValidator<ChangeMyPasswordCommand> changePasswordValidator)
|
||||||
{
|
{
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
_validator = validator;
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
_changePasswordValidator = changePasswordValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new user. Requires admin role.</summary>
|
/// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:usuarios:gestionar")]
|
||||||
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
@@ -38,7 +60,7 @@ public sealed class UsuariosController : ControllerBase
|
|||||||
Email: request.Email,
|
Email: request.Email,
|
||||||
Rol: request.Rol ?? string.Empty);
|
Rol: request.Rol ?? string.Empty);
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(command);
|
var validation = await _createValidator.ValidateAsync(command);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
{
|
{
|
||||||
var errors = validation.Errors
|
var errors = validation.Errors
|
||||||
@@ -51,8 +73,162 @@ public sealed class UsuariosController : ControllerBase
|
|||||||
|
|
||||||
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
|
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 validation = await _updateValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<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);
|
||||||
|
|
||||||
|
var validation = await _changePasswordValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dispatcher.Send<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>
|
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
||||||
public sealed record CreateUsuarioRequest(
|
public sealed record CreateUsuarioRequest(
|
||||||
string? Username,
|
string? Username,
|
||||||
@@ -61,3 +237,14 @@ public sealed record CreateUsuarioRequest(
|
|||||||
string? Apellido,
|
string? Apellido,
|
||||||
string? Email,
|
string? Email,
|
||||||
string? Rol);
|
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)
|
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:
|
case UsernameAlreadyExistsException usernameEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
@@ -8,4 +9,12 @@ public interface IUsuarioRepository
|
|||||||
Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default);
|
Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default);
|
||||||
Task<int> AddAsync(Usuario usuario, CancellationToken ct = default);
|
Task<int> AddAsync(Usuario usuario, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// UDT-008
|
||||||
|
Task UpdateUltimoLoginAsync(int id, DateTime utcNow, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<UsuarioListItem>> GetPagedAsync(UsuariosQuery query, CancellationToken ct = default);
|
||||||
|
Task<Usuario?> GetDetailAsync(int id, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default);
|
||||||
|
Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default);
|
||||||
|
Task<int> CountActiveAdminsAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
@@ -17,6 +18,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
private readonly IClientContext _clientContext;
|
private readonly IClientContext _clientContext;
|
||||||
private readonly AuthOptions _authOptions;
|
private readonly AuthOptions _authOptions;
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||||
|
private readonly ILogger<LoginCommandHandler> _logger;
|
||||||
|
|
||||||
public LoginCommandHandler(
|
public LoginCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
@@ -26,7 +28,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
IRefreshTokenGenerator refreshGenerator,
|
IRefreshTokenGenerator refreshGenerator,
|
||||||
IClientContext clientContext,
|
IClientContext clientContext,
|
||||||
AuthOptions authOptions,
|
AuthOptions authOptions,
|
||||||
IRolPermisoRepository rolPermisoRepository)
|
IRolPermisoRepository rolPermisoRepository,
|
||||||
|
ILogger<LoginCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
@@ -36,6 +39,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
_clientContext = clientContext;
|
_clientContext = clientContext;
|
||||||
_authOptions = authOptions;
|
_authOptions = authOptions;
|
||||||
_rolPermisoRepository = rolPermisoRepository;
|
_rolPermisoRepository = rolPermisoRepository;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
||||||
@@ -61,8 +65,18 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
_clientContext.Ip, _clientContext.UserAgent);
|
_clientContext.Ip, _clientContext.UserAgent);
|
||||||
await _refreshRepository.AddAsync(entity);
|
await _refreshRepository.AddAsync(entity);
|
||||||
|
|
||||||
|
// UDT-008: update UltimoLogin best-effort — never block login on this
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _repository.UpdateUltimoLoginAsync(usuario.Id, now);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to update UltimoLogin for usuario {Id} — login proceeds", usuario.Id);
|
||||||
|
}
|
||||||
|
|
||||||
// UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson
|
// UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson
|
||||||
// Usuario.PermisosJson queda reservado para UDT-008 (overrides por usuario)
|
// Usuario.PermisosJson queda reservado para UDT-009 (overrides por usuario)
|
||||||
var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
|
var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
|
||||||
var permisos = permisoEntities.Select(p => p.Codigo).ToArray();
|
var permisos = permisoEntities.Select(p => p.Codigo).ToArray();
|
||||||
|
|
||||||
@@ -72,9 +86,11 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
|
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
|
||||||
Usuario: new UsuarioDto(
|
Usuario: new UsuarioDto(
|
||||||
Id: usuario.Id,
|
Id: usuario.Id,
|
||||||
|
Username: usuario.Username,
|
||||||
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
|
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
|
||||||
Rol: usuario.Rol,
|
Rol: usuario.Rol,
|
||||||
Permisos: permisos
|
Permisos: permisos,
|
||||||
|
MustChangePassword: usuario.MustChangePassword
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public sealed record LoginResponseDto(
|
|||||||
|
|
||||||
public sealed record UsuarioDto(
|
public sealed record UsuarioDto(
|
||||||
int Id,
|
int Id,
|
||||||
|
string Username, // UDT-008
|
||||||
string Nombre,
|
string Nombre,
|
||||||
string Rol,
|
string Rol,
|
||||||
string[] Permisos
|
string[] Permisos,
|
||||||
|
bool MustChangePassword // UDT-008
|
||||||
);
|
);
|
||||||
|
|||||||
9
src/api/SIGCM2.Application/Common/PagedResult.cs
Normal file
9
src/api/SIGCM2.Application/Common/PagedResult.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Generic paged result for list queries.</summary>
|
||||||
|
public sealed record PagedResult<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int Total
|
||||||
|
);
|
||||||
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;
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs
Normal file
10
src/api/SIGCM2.Application/Common/UpdateUsuarioFields.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Mutable fields for updating a usuario profile. Username and PasswordHash are immutable.</summary>
|
||||||
|
public sealed record UpdateUsuarioFields(
|
||||||
|
string Nombre,
|
||||||
|
string Apellido,
|
||||||
|
string? Email,
|
||||||
|
string Rol,
|
||||||
|
bool Activo
|
||||||
|
);
|
||||||
14
src/api/SIGCM2.Application/Common/UsuarioListItem.cs
Normal file
14
src/api/SIGCM2.Application/Common/UsuarioListItem.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Light projection of a usuario for list views.</summary>
|
||||||
|
public sealed record UsuarioListItem(
|
||||||
|
int Id,
|
||||||
|
string Username,
|
||||||
|
string Nombre,
|
||||||
|
string Apellido,
|
||||||
|
string? Email,
|
||||||
|
string Rol,
|
||||||
|
bool Activo,
|
||||||
|
DateTime? UltimoLogin,
|
||||||
|
DateTime? FechaModificacion
|
||||||
|
);
|
||||||
10
src/api/SIGCM2.Application/Common/UsuariosQuery.cs
Normal file
10
src/api/SIGCM2.Application/Common/UsuariosQuery.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing usuarios with optional filters and paging.</summary>
|
||||||
|
public sealed record UsuariosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
string? Rol,
|
||||||
|
bool? Activo,
|
||||||
|
string? Search
|
||||||
|
);
|
||||||
@@ -4,6 +4,7 @@ using SIGCM2.Application.Abstractions;
|
|||||||
using SIGCM2.Application.Auth.Login;
|
using SIGCM2.Application.Auth.Login;
|
||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
using SIGCM2.Application.Auth.Refresh;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Permisos.Assign;
|
using SIGCM2.Application.Permisos.Assign;
|
||||||
using SIGCM2.Application.Permisos.Dtos;
|
using SIGCM2.Application.Permisos.Dtos;
|
||||||
using SIGCM2.Application.Permisos.GetByRol;
|
using SIGCM2.Application.Permisos.GetByRol;
|
||||||
@@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos;
|
|||||||
using SIGCM2.Application.Roles.Get;
|
using SIGCM2.Application.Roles.Get;
|
||||||
using SIGCM2.Application.Roles.List;
|
using SIGCM2.Application.Roles.List;
|
||||||
using SIGCM2.Application.Roles.Update;
|
using SIGCM2.Application.Roles.Update;
|
||||||
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
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;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -40,6 +48,15 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
|
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)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ public sealed class Usuario
|
|||||||
public string PermisosJson { get; }
|
public string PermisosJson { get; }
|
||||||
public bool Activo { get; }
|
public bool Activo { get; }
|
||||||
|
|
||||||
|
// UDT-008: new properties
|
||||||
|
public DateTime? FechaModificacion { get; }
|
||||||
|
public DateTime? UltimoLogin { get; }
|
||||||
|
public bool MustChangePassword { get; }
|
||||||
|
|
||||||
public Usuario(
|
public Usuario(
|
||||||
int id,
|
int id,
|
||||||
string username,
|
string username,
|
||||||
@@ -21,7 +26,10 @@ public sealed class Usuario
|
|||||||
string? email,
|
string? email,
|
||||||
string rol,
|
string rol,
|
||||||
string permisosJson,
|
string permisosJson,
|
||||||
bool activo)
|
bool activo,
|
||||||
|
DateTime? fechaModificacion = null,
|
||||||
|
DateTime? ultimoLogin = null,
|
||||||
|
bool mustChangePassword = false)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Username = username;
|
Username = username;
|
||||||
@@ -32,11 +40,14 @@ public sealed class Usuario
|
|||||||
Rol = rol;
|
Rol = rol;
|
||||||
PermisosJson = permisosJson;
|
PermisosJson = permisosJson;
|
||||||
Activo = activo;
|
Activo = activo;
|
||||||
|
FechaModificacion = fechaModificacion;
|
||||||
|
UltimoLogin = ultimoLogin;
|
||||||
|
MustChangePassword = mustChangePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
|
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
|
||||||
/// Defaults: Activo=true, PermisosJson="[]".
|
/// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Usuario ForCreation(
|
public static Usuario ForCreation(
|
||||||
string username,
|
string username,
|
||||||
@@ -55,6 +66,87 @@ public sealed class Usuario
|
|||||||
email: email,
|
email: email,
|
||||||
rol: rol,
|
rol: rol,
|
||||||
permisosJson: "[]",
|
permisosJson: "[]",
|
||||||
activo: true);
|
activo: true,
|
||||||
|
fechaModificacion: null,
|
||||||
|
ultimoLogin: null,
|
||||||
|
mustChangePassword: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: copy-with factory methods ────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with updated profile fields.
|
||||||
|
/// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable.
|
||||||
|
/// </summary>
|
||||||
|
public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
username: Username,
|
||||||
|
passwordHash: PasswordHash,
|
||||||
|
nombre: nombre,
|
||||||
|
apellido: apellido,
|
||||||
|
email: email,
|
||||||
|
rol: rol,
|
||||||
|
permisosJson: PermisosJson,
|
||||||
|
activo: activo,
|
||||||
|
fechaModificacion: DateTime.UtcNow,
|
||||||
|
ultimoLogin: UltimoLogin,
|
||||||
|
mustChangePassword: MustChangePassword);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with a new password hash and mustChangePassword flag.
|
||||||
|
/// Sets FechaModificacion = UtcNow.
|
||||||
|
/// </summary>
|
||||||
|
public Usuario WithNewPasswordHash(string hash, bool mustChangePassword)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
username: Username,
|
||||||
|
passwordHash: hash,
|
||||||
|
nombre: Nombre,
|
||||||
|
apellido: Apellido,
|
||||||
|
email: Email,
|
||||||
|
rol: Rol,
|
||||||
|
permisosJson: PermisosJson,
|
||||||
|
activo: Activo,
|
||||||
|
fechaModificacion: DateTime.UtcNow,
|
||||||
|
ultimoLogin: UltimoLogin,
|
||||||
|
mustChangePassword: mustChangePassword);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with only the MustChangePassword flag changed.
|
||||||
|
/// Sets FechaModificacion = UtcNow.
|
||||||
|
/// </summary>
|
||||||
|
public Usuario WithMustChangePassword(bool value)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
username: Username,
|
||||||
|
passwordHash: PasswordHash,
|
||||||
|
nombre: Nombre,
|
||||||
|
apellido: Apellido,
|
||||||
|
email: Email,
|
||||||
|
rol: Rol,
|
||||||
|
permisosJson: PermisosJson,
|
||||||
|
activo: Activo,
|
||||||
|
fechaModificacion: DateTime.UtcNow,
|
||||||
|
ultimoLogin: UltimoLogin,
|
||||||
|
mustChangePassword: value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with only UltimoLogin updated.
|
||||||
|
/// Does NOT touch FechaModificacion.
|
||||||
|
/// </summary>
|
||||||
|
public Usuario WithUltimoLogin(DateTime utcNow)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
username: Username,
|
||||||
|
passwordHash: PasswordHash,
|
||||||
|
nombre: Nombre,
|
||||||
|
apellido: Apellido,
|
||||||
|
email: Email,
|
||||||
|
rol: Rol,
|
||||||
|
permisosJson: PermisosJson,
|
||||||
|
activo: Activo,
|
||||||
|
fechaModificacion: FechaModificacion,
|
||||||
|
ultimoLogin: utcNow,
|
||||||
|
mustChangePassword: MustChangePassword);
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs
Normal file
11
src/api/SIGCM2.Domain/Exceptions/CannotSelfResetException.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an admin attempts to reset their own password via the admin reset endpoint.
|
||||||
|
/// Admin must use the self-service change password endpoint instead.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CannotSelfResetException : DomainException
|
||||||
|
{
|
||||||
|
public CannotSelfResetException()
|
||||||
|
: base("Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio.") { }
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Domain/Exceptions/DomainException.cs
Normal file
10
src/api/SIGCM2.Domain/Exceptions/DomainException.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all domain-level exceptions in SIGCM2.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class DomainException : Exception
|
||||||
|
{
|
||||||
|
protected DomainException(string message) : base(message) { }
|
||||||
|
protected DomainException(string message, Exception innerException) : base(message, innerException) { }
|
||||||
|
}
|
||||||
@@ -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.") { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an operation would remove the last active admin from the system,
|
||||||
|
/// causing a lockout condition.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LastAdminLockoutException : DomainException
|
||||||
|
{
|
||||||
|
public LastAdminLockoutException()
|
||||||
|
: base("No se puede desactivar o cambiar el rol del último administrador activo.") { }
|
||||||
|
}
|
||||||
15
src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/UsuarioNotFoundException.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested user does not exist in the system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UsuarioNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public UsuarioNotFoundException(int id)
|
||||||
|
: base($"El usuario con id '{id}' no existe.")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Text;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
namespace SIGCM2.Infrastructure.Persistence;
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
@@ -19,7 +21,8 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
SELECT
|
SELECT
|
||||||
Id, Username, PasswordHash,
|
Id, Username, PasswordHash,
|
||||||
Nombre, Apellido, Email,
|
Nombre, Apellido, Email,
|
||||||
Rol, PermisosJson, Activo
|
Rol, PermisosJson, Activo,
|
||||||
|
FechaModificacion, UltimoLogin, MustChangePassword
|
||||||
FROM dbo.Usuario
|
FROM dbo.Usuario
|
||||||
WHERE Username = @Username
|
WHERE Username = @Username
|
||||||
AND Activo = 1
|
AND Activo = 1
|
||||||
@@ -41,7 +44,8 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
SELECT
|
SELECT
|
||||||
Id, Username, PasswordHash,
|
Id, Username, PasswordHash,
|
||||||
Nombre, Apellido, Email,
|
Nombre, Apellido, Email,
|
||||||
Rol, PermisosJson, Activo
|
Rol, PermisosJson, Activo,
|
||||||
|
FechaModificacion, UltimoLogin, MustChangePassword
|
||||||
FROM dbo.Usuario
|
FROM dbo.Usuario
|
||||||
WHERE Id = @Id
|
WHERE Id = @Id
|
||||||
""";
|
""";
|
||||||
@@ -94,6 +98,136 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UDT-008 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task UpdateUltimoLoginAsync(int id, DateTime utcNow, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Usuario SET UltimoLogin = @Utc WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
await connection.ExecuteAsync(sql, new { Utc = utcNow, Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<UsuarioListItem>> GetPagedAsync(UsuariosQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Clamp paging params
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var where = new StringBuilder("WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("PageSize", pageSize);
|
||||||
|
parameters.Add("Offset", offset);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Rol))
|
||||||
|
{
|
||||||
|
where.Append(" AND Rol = @Rol");
|
||||||
|
parameters.Add("Rol", query.Rol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Activo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Activo = @Activo");
|
||||||
|
parameters.Add("Activo", query.Activo.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
{
|
||||||
|
where.Append(" AND (Username LIKE @Search OR Nombre LIKE @Search OR Apellido LIKE @Search OR Email LIKE @Search)");
|
||||||
|
parameters.Add("Search", $"%{query.Search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT
|
||||||
|
Id, Username, Nombre, Apellido, Email, Rol, Activo, UltimoLogin, FechaModificacion,
|
||||||
|
COUNT(*) OVER() AS TotalCount
|
||||||
|
FROM dbo.Usuario
|
||||||
|
{where}
|
||||||
|
ORDER BY Username
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<UsuarioPagedRow>(sql, parameters);
|
||||||
|
var list = rows.ToList();
|
||||||
|
|
||||||
|
var total = list.Count > 0 ? list[0].TotalCount : 0;
|
||||||
|
var items = list.Select(r => new UsuarioListItem(
|
||||||
|
r.Id, r.Username, r.Nombre, r.Apellido, r.Email, r.Rol, r.Activo, r.UltimoLogin, r.FechaModificacion
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<UsuarioListItem>(items, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Usuario?> GetDetailAsync(int id, CancellationToken ct = default)
|
||||||
|
=> await GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Usuario
|
||||||
|
SET Nombre = @Nombre,
|
||||||
|
Apellido = @Apellido,
|
||||||
|
Email = @Email,
|
||||||
|
Rol = @Rol,
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = @FechaModificacion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
fields.Nombre,
|
||||||
|
fields.Apellido,
|
||||||
|
fields.Email,
|
||||||
|
fields.Rol,
|
||||||
|
fields.Activo,
|
||||||
|
FechaModificacion = fechaModificacion,
|
||||||
|
Id = id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Usuario
|
||||||
|
SET PasswordHash = @PasswordHash,
|
||||||
|
MustChangePassword = @MustChangePassword,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
PasswordHash = passwordHash,
|
||||||
|
MustChangePassword = mustChangePassword ? 1 : 0,
|
||||||
|
Id = id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountActiveAdminsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1) FROM dbo.Usuario WITH (NOLOCK) WHERE Activo = 1 AND Rol = 'admin'
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static Usuario MapRow(UsuarioRow row)
|
private static Usuario MapRow(UsuarioRow row)
|
||||||
=> new(
|
=> new(
|
||||||
id: row.Id,
|
id: row.Id,
|
||||||
@@ -104,7 +238,10 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
email: row.Email,
|
email: row.Email,
|
||||||
rol: row.Rol,
|
rol: row.Rol,
|
||||||
permisosJson: row.PermisosJson,
|
permisosJson: row.PermisosJson,
|
||||||
activo: row.Activo
|
activo: row.Activo,
|
||||||
|
fechaModificacion: row.FechaModificacion,
|
||||||
|
ultimoLogin: row.UltimoLogin,
|
||||||
|
mustChangePassword: row.MustChangePassword
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
|
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
|
||||||
@@ -117,6 +254,22 @@ public sealed class UsuarioRepository : IUsuarioRepository
|
|||||||
string? Email,
|
string? Email,
|
||||||
string Rol,
|
string Rol,
|
||||||
string PermisosJson,
|
string PermisosJson,
|
||||||
bool Activo
|
bool Activo,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
DateTime? UltimoLogin,
|
||||||
|
bool MustChangePassword
|
||||||
|
);
|
||||||
|
|
||||||
|
private sealed record UsuarioPagedRow(
|
||||||
|
int Id,
|
||||||
|
string Username,
|
||||||
|
string Nombre,
|
||||||
|
string Apellido,
|
||||||
|
string? Email,
|
||||||
|
string Rol,
|
||||||
|
bool Activo,
|
||||||
|
DateTime? UltimoLogin,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
int TotalCount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Menu, LogOut, User } from 'lucide-react'
|
import { Menu, LogOut, User, Lock } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -85,6 +85,10 @@ export function AppHeader() {
|
|||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Mi perfil
|
Mi perfil
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void navigate('/perfil/contrasena')}>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Cambiar contraseña
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
Settings,
|
Settings,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Users,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -94,6 +95,18 @@ export function SidebarNav() {
|
|||||||
Administración
|
Administración
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/usuarios"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
|
pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Usuarios</span>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/usuarios/nuevo"
|
to="/usuarios/nuevo"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
28
src/web/src/components/routing/MustChangePasswordGate.tsx
Normal file
28
src/web/src/components/routing/MustChangePasswordGate.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
interface MustChangePasswordGateProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router guard for the "must change password" flow (UDT-008).
|
||||||
|
*
|
||||||
|
* If the authenticated user has mustChangePassword=true and is NOT already
|
||||||
|
* on /perfil/contrasena, redirects them there.
|
||||||
|
*
|
||||||
|
* Place this INSIDE ProtectedRoute so it only fires for authenticated users.
|
||||||
|
* The /perfil/contrasena route itself must NOT be wrapped with this gate
|
||||||
|
* to avoid redirect loops.
|
||||||
|
*/
|
||||||
|
export function MustChangePasswordGate({ children }: MustChangePasswordGateProps) {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (user?.mustChangePassword && location.pathname !== '/perfil/contrasena') {
|
||||||
|
return <Navigate to="/perfil/contrasena" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface LoginResponseDto {
|
|||||||
nombre: string
|
nombre: string
|
||||||
rol: string
|
rol: string
|
||||||
permisos: string[]
|
permisos: string[]
|
||||||
|
mustChangePassword: boolean // UDT-008
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function useLogin() {
|
|||||||
nombre: data.usuario.nombre,
|
nombre: data.usuario.nombre,
|
||||||
rol: data.usuario.rol,
|
rol: data.usuario.rol,
|
||||||
permisos: data.usuario.permisos ?? [],
|
permisos: data.usuario.permisos ?? [],
|
||||||
|
mustChangePassword: data.usuario.mustChangePassword ?? false, // UDT-008
|
||||||
},
|
},
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken,
|
||||||
refreshToken: data.refreshToken,
|
refreshToken: data.refreshToken,
|
||||||
|
|||||||
10
src/web/src/features/profile/api/changeMyPassword.ts
Normal file
10
src/web/src/features/profile/api/changeMyPassword.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export interface ChangeMyPasswordRequest {
|
||||||
|
oldPassword: string
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeMyPassword(payload: ChangeMyPasswordRequest): Promise<void> {
|
||||||
|
await axiosClient.put('/api/v1/users/me/password', payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { changeMyPassword } from '../api/changeMyPassword'
|
||||||
|
import type { ChangeMyPasswordRequest } from '../api/changeMyPassword'
|
||||||
|
|
||||||
|
export function useChangeMyPassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ChangeMyPasswordRequest) => changeMyPassword(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
121
src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx
Normal file
121
src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { useChangeMyPassword } from '../hooks/useChangeMyPassword'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
export function ChangeMyPasswordPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mutate, isPending } = useChangeMyPassword()
|
||||||
|
const updateUser = useAuthStore((s) => s.updateUser)
|
||||||
|
|
||||||
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [clientError, setClientError] = useState<string | null>(null)
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setClientError(null)
|
||||||
|
setServerError(null)
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setClientError('Las contraseñas no coinciden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
{ oldPassword, newPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Clear mustChangePassword flag in store
|
||||||
|
updateUser({ mustChangePassword: false })
|
||||||
|
toast.success('Contraseña actualizada correctamente')
|
||||||
|
navigate('/')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; title?: string }
|
||||||
|
if (data.error === 'invalid-old-password' || data.title === 'invalid-old-password') {
|
||||||
|
setServerError('La contraseña actual es incorrecta')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setServerError('Error al cambiar la contraseña. Intentá nuevamente.')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold">Cambiar contraseña</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
|
{clientError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{clientError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serverError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{serverError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="oldPassword">Contraseña actual</Label>
|
||||||
|
<Input
|
||||||
|
id="oldPassword"
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label="Contraseña actual"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-label="Nueva contraseña"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="confirmPassword">Confirmar nueva contraseña</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-label="Confirmar contraseña"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Cambiando...' : 'Cambiar contraseña'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/web/src/features/users/api/deactivateUser.ts
Normal file
7
src/web/src/features/users/api/deactivateUser.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UserDetail } from '../types'
|
||||||
|
|
||||||
|
export async function deactivateUser(id: number): Promise<UserDetail> {
|
||||||
|
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/deactivate`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
7
src/web/src/features/users/api/getUser.ts
Normal file
7
src/web/src/features/users/api/getUser.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UserDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getUser(id: number): Promise<UserDetail> {
|
||||||
|
const response = await axiosClient.get<UserDetail>(`/api/v1/users/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
15
src/web/src/features/users/api/listUsers.ts
Normal file
15
src/web/src/features/users/api/listUsers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { PagedResult, UserListItem, UsuariosQuery } from '../types'
|
||||||
|
|
||||||
|
export async function listUsers(query: UsuariosQuery): Promise<PagedResult<UserListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.rol !== undefined && query.rol !== '') params.set('rol', query.rol)
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.search !== undefined && query.search !== '') params.set('search', query.search)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<UserListItem>>('/api/v1/users', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
7
src/web/src/features/users/api/reactivateUser.ts
Normal file
7
src/web/src/features/users/api/reactivateUser.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UserDetail } from '../types'
|
||||||
|
|
||||||
|
export async function reactivateUser(id: number): Promise<UserDetail> {
|
||||||
|
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/reactivate`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
13
src/web/src/features/users/api/resetUserPassword.ts
Normal file
13
src/web/src/features/users/api/resetUserPassword.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export interface ResetPasswordResponse {
|
||||||
|
tempPassword: string
|
||||||
|
mustChangeOnLogin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetUserPassword(userId: number): Promise<ResetPasswordResponse> {
|
||||||
|
const response = await axiosClient.post<ResetPasswordResponse>(
|
||||||
|
`/api/v1/users/${userId}/password/reset`,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
13
src/web/src/features/users/api/updateUser.ts
Normal file
13
src/web/src/features/users/api/updateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UserDetail, UpdateUserPayload } from '../types'
|
||||||
|
|
||||||
|
export async function updateUser(id: number, payload: UpdateUserPayload): Promise<UserDetail> {
|
||||||
|
const response = await axiosClient.put<UserDetail>(`/api/v1/users/${id}`, {
|
||||||
|
nombre: payload.nombre,
|
||||||
|
apellido: payload.apellido,
|
||||||
|
email: payload.email,
|
||||||
|
rol: payload.rol,
|
||||||
|
activo: payload.activo,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
157
src/web/src/features/users/components/ResetPasswordModal.tsx
Normal file
157
src/web/src/features/users/components/ResetPasswordModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { Copy, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { useResetUserPassword } from '../hooks/useResetUserPassword'
|
||||||
|
|
||||||
|
interface ResetPasswordModalProps {
|
||||||
|
userId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalState = 'idle' | 'confirming' | 'showing-password' | 'error'
|
||||||
|
|
||||||
|
export function ResetPasswordModal({ userId }: ResetPasswordModalProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [modalState, setModalState] = useState<ModalState>('idle')
|
||||||
|
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
||||||
|
const [copyDone, setCopyDone] = useState(false)
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { mutate, isPending } = useResetUserPassword()
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
setModalState('confirming')
|
||||||
|
setTempPassword(null)
|
||||||
|
setCopyDone(false)
|
||||||
|
setErrorMsg(null)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setOpen(false)
|
||||||
|
setModalState('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
mutate(userId, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTempPassword(data.tempPassword)
|
||||||
|
setModalState('showing-password')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setErrorMsg('Error al resetear la contraseña. Intentá de nuevo.')
|
||||||
|
setModalState('error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (tempPassword) {
|
||||||
|
await navigator.clipboard.writeText(tempPassword)
|
||||||
|
setCopyDone(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleOpen}>
|
||||||
|
Resetear contraseña
|
||||||
|
</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-40" />
|
||||||
|
<Dialog.Content
|
||||||
|
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-lg shadow-xl p-6 z-50 w-full max-w-md space-y-4"
|
||||||
|
aria-describedby="reset-pwd-desc"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Dialog.Title className="text-base font-semibold">
|
||||||
|
Resetear contraseña
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleCancel} aria-label="Cerrar">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Description id="reset-pwd-desc" className="sr-only">
|
||||||
|
Resetear contraseña del usuario
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
{modalState === 'confirming' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
¿Estás seguro que querés resetear la contraseña de este usuario?
|
||||||
|
Se generará una contraseña temporal y se invalidarán todas sus sesiones activas.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Reseteando...' : 'Confirmar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalState === 'showing-password' && tempPassword && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Esta es la única vez que verás esta contraseña. Copiála ahora.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted p-3">
|
||||||
|
<p className="font-mono text-base tracking-widest select-all">{tempPassword}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{copyDone ? '¡Copiado!' : 'Copiar al portapapeles'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
setModalState('idle')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalState === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{errorMsg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setModalState('confirming'); setErrorMsg(null) }}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/web/src/features/users/components/UsersFilters.tsx
Normal file
69
src/web/src/features/users/components/UsersFilters.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
|
||||||
|
interface UsersFiltersProps {
|
||||||
|
onRolChange: (rol: string) => void
|
||||||
|
onActivoChange: (activo: boolean | undefined) => void
|
||||||
|
/** Called with the debounced search string (300ms) */
|
||||||
|
onSearchChange: (search: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROL_OPTIONS = [
|
||||||
|
{ value: '', label: 'Todos los roles' },
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'cajero', label: 'Cajero' },
|
||||||
|
{ value: 'reportes', label: 'Reportes' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: UsersFiltersProps) {
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const debouncedSearch = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
// Propagate debounced search to parent
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChange(debouncedSearch)
|
||||||
|
}, [debouncedSearch, onSearchChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||||
|
{/* Search input */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por usuario, nombre, email..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => setSearchRaw(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar usuarios"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rol select */}
|
||||||
|
<select
|
||||||
|
aria-label="Rol"
|
||||||
|
onChange={(e) => onRolChange(e.target.value)}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{ROL_OPTIONS.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Activo filter */}
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '') onActivoChange(undefined)
|
||||||
|
else onActivoChange(v === 'true')
|
||||||
|
}}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/web/src/features/users/components/UsersTable.tsx
Normal file
73
src/web/src/features/users/components/UsersTable.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { UserListItem } from '../types'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
interface UsersTableProps {
|
||||||
|
rows: UserListItem[]
|
||||||
|
onRowClick: (user: UserListItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Sin resultados — no se encontraron usuarios con los filtros seleccionados.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Usuario</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Nombre</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Rol</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Estado</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Último login</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => onRowClick(u)}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{u.username}</td>
|
||||||
|
<td className="px-4 py-3">{`${u.nombre} ${u.apellido}`}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{u.email ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{u.rol}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{u.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{formatDate(u.ultimoLogin)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/users/hooks/useDeactivateUser.ts
Normal file
13
src/web/src/features/users/hooks/useDeactivateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateUser } from '../api/deactivateUser'
|
||||||
|
|
||||||
|
export function useDeactivateUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/users/hooks/useReactivateUser.ts
Normal file
13
src/web/src/features/users/hooks/useReactivateUser.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateUser } from '../api/reactivateUser'
|
||||||
|
|
||||||
|
export function useReactivateUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
8
src/web/src/features/users/hooks/useResetUserPassword.ts
Normal file
8
src/web/src/features/users/hooks/useResetUserPassword.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { resetUserPassword } from '../api/resetUserPassword'
|
||||||
|
|
||||||
|
export function useResetUserPassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (userId: number) => resetUserPassword(userId),
|
||||||
|
})
|
||||||
|
}
|
||||||
15
src/web/src/features/users/hooks/useUpdateUser.ts
Normal file
15
src/web/src/features/users/hooks/useUpdateUser.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateUser } from '../api/updateUser'
|
||||||
|
import type { UpdateUserPayload } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateUser(userId: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateUserPayload) => updateUser(userId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate both the detail and the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/users/hooks/useUser.ts
Normal file
13
src/web/src/features/users/hooks/useUser.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getUser } from '../api/getUser'
|
||||||
|
|
||||||
|
export const userQueryKey = (id: number) => ['users', id] as const
|
||||||
|
|
||||||
|
export function useUser(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userQueryKey(id),
|
||||||
|
queryFn: () => getUser(id),
|
||||||
|
staleTime: 15_000,
|
||||||
|
enabled: id > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/users/hooks/useUsersList.ts
Normal file
13
src/web/src/features/users/hooks/useUsersList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listUsers } from '../api/listUsers'
|
||||||
|
import type { UsuariosQuery } from '../types'
|
||||||
|
|
||||||
|
export const usersListQueryKey = (query: UsuariosQuery) => ['users', 'list', query] as const
|
||||||
|
|
||||||
|
export function useUsersList(query: UsuariosQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: usersListQueryKey(query),
|
||||||
|
queryFn: () => listUsers(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
src/web/src/features/users/pages/UserDetailPage.tsx
Normal file
101
src/web/src/features/users/pages/UserDetailPage.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { useUser } from '../hooks/useUser'
|
||||||
|
import { useDeactivateUser } from '../hooks/useDeactivateUser'
|
||||||
|
import { useReactivateUser } from '../hooks/useReactivateUser'
|
||||||
|
import { ResetPasswordModal } from '../components/ResetPasswordModal'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
export function UserDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const userId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const loggedUserId = useAuthStore((s) => s.user?.id)
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useUser(userId)
|
||||||
|
const { mutate: deactivate, isPending: deactivating } = useDeactivateUser()
|
||||||
|
const { mutate: reactivate, isPending: reactivating } = useReactivateUser()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Usuario no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = deactivating || reactivating
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{user.nombre} {user.apellido}
|
||||||
|
</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Usuario</span>
|
||||||
|
<span className="font-mono">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Email</span>
|
||||||
|
<span>{user.email ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Rol</span>
|
||||||
|
<Badge variant="secondary" className="capitalize">{user.rol}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estado</span>
|
||||||
|
{user.activo
|
||||||
|
? <Badge variant="secondary" className="bg-green-100 text-green-800">Activo</Badge>
|
||||||
|
: <Badge variant="secondary" className="bg-red-100 text-red-800">Inactivo</Badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/usuarios/${userId}/editar`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user.activo ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => deactivate(userId)}
|
||||||
|
>
|
||||||
|
{deactivating ? 'Desactivando...' : 'Desactivar'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => reactivate(userId)}
|
||||||
|
>
|
||||||
|
{reactivating ? 'Reactivando...' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loggedUserId !== userId && <ResetPasswordModal userId={userId} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
src/web/src/features/users/pages/UserEditPage.tsx
Normal file
233
src/web/src/features/users/pages/UserEditPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useUser } from '../hooks/useUser'
|
||||||
|
import { useUpdateUser } from '../hooks/useUpdateUser'
|
||||||
|
import { ResetPasswordModal } from '../components/ResetPasswordModal'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const editSchema = z.object({
|
||||||
|
nombre: z.string().min(1, 'El nombre es requerido'),
|
||||||
|
apellido: z.string().min(1, 'El apellido es requerido'),
|
||||||
|
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||||
|
rol: z.string().min(1, 'Seleccioná un rol válido'),
|
||||||
|
activo: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type EditFormValues = z.infer<typeof editSchema>
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { title?: string; error?: string; message?: string }
|
||||||
|
if (data.title === 'last-admin-lockout' || data.error === 'last-admin-lockout') {
|
||||||
|
return 'No podés cambiar el rol o desactivar al último administrador activo'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al actualizar el usuario'
|
||||||
|
}
|
||||||
|
return 'Error al actualizar el usuario'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const userId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const loggedUserId = useAuthStore((s) => s.user?.id)
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useUser(userId)
|
||||||
|
const { mutate, isPending, error } = useUpdateUser(userId)
|
||||||
|
|
||||||
|
const form = useForm<EditFormValues>({
|
||||||
|
resolver: zodResolver(editSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nombre: '',
|
||||||
|
apellido: '',
|
||||||
|
email: '',
|
||||||
|
rol: '',
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prefill form when user data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
form.reset({
|
||||||
|
nombre: user.nombre,
|
||||||
|
apellido: user.apellido,
|
||||||
|
email: user.email ?? '',
|
||||||
|
rol: user.rol,
|
||||||
|
activo: user.activo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [user, form])
|
||||||
|
|
||||||
|
function handleSubmit(values: EditFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
nombre: values.nombre,
|
||||||
|
apellido: values.apellido,
|
||||||
|
email: values.email || null,
|
||||||
|
rol: values.rol,
|
||||||
|
activo: values.activo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/usuarios')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Usuario no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Editar Usuario</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loggedUserId !== userId && <ResetPasswordModal userId={userId} />}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username — display only, not editable */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Usuario</p>
|
||||||
|
<p className="text-sm font-mono bg-muted rounded px-3 py-2">{user.username}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={isPending} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="apellido"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Apellido</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={isPending} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="email" disabled={isPending} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Rol</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Rol"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="cajero">Cajero</option>
|
||||||
|
<option value="reportes">Reportes</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="activo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-3">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Activo"
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Activo</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/web/src/features/users/pages/UsersListPage.tsx
Normal file
119
src/web/src/features/users/pages/UsersListPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { UsersTable } from '../components/UsersTable'
|
||||||
|
import { UsersFilters } from '../components/UsersFilters'
|
||||||
|
import { useUsersList } from '../hooks/useUsersList'
|
||||||
|
import type { UserListItem } from '../types'
|
||||||
|
|
||||||
|
export function UsersListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [rol, setRol] = useState<string>('')
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(undefined)
|
||||||
|
const [search, setSearch] = useState<string>('')
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(rol ? { rol } : {}),
|
||||||
|
...(activo !== undefined ? { activo } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useUsersList(query)
|
||||||
|
|
||||||
|
const handleRolChange = useCallback(
|
||||||
|
(newRol: string) => {
|
||||||
|
setRol(newRol)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleActivoChange = useCallback(
|
||||||
|
(newActivo: boolean | undefined) => {
|
||||||
|
setActivo(newActivo)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(newSearch: string) => {
|
||||||
|
setSearch(newSearch)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(user: UserListItem) => {
|
||||||
|
navigate(`/usuarios/${user.id}/editar`)
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Usuarios</h1>
|
||||||
|
<Button onClick={() => navigate('/usuarios/nuevo')} size="sm">
|
||||||
|
Nuevo usuario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsersFilters
|
||||||
|
onRolChange={handleRolChange}
|
||||||
|
onActivoChange={handleActivoChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UsersTable rows={data?.items ?? []} onRowClick={handleRowClick} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} usuario${data.total !== 1 ? 's' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/web/src/features/users/types.ts
Normal file
48
src/web/src/features/users/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// UDT-008 — shared types for users feature
|
||||||
|
|
||||||
|
export interface UserListItem {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
ultimoLogin: string | null // ISO datetime or null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetail {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
mustChangePassword: boolean
|
||||||
|
ultimoLogin: string | null
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsuariosQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
rol?: string
|
||||||
|
activo?: boolean
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPayload {
|
||||||
|
nombre: string
|
||||||
|
apellido: string
|
||||||
|
email: string | null
|
||||||
|
rol: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
17
src/web/src/hooks/useDebouncedValue.ts
Normal file
17
src/web/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a debounced version of the value.
|
||||||
|
* The debounced value only updates after `delay` ms have elapsed
|
||||||
|
* since the last change.
|
||||||
|
*/
|
||||||
|
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(value), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import { ProtectedRoute } from './components/routing/ProtectedRoute'
|
import { ProtectedRoute } from './components/routing/ProtectedRoute'
|
||||||
|
import { MustChangePasswordGate } from './components/routing/MustChangePasswordGate'
|
||||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||||
|
import { UsersListPage } from './features/users/pages/UsersListPage'
|
||||||
|
import { UserDetailPage } from './features/users/pages/UserDetailPage'
|
||||||
|
import { UserEditPage } from './features/users/pages/UserEditPage'
|
||||||
|
import { ChangeMyPasswordPage } from './features/profile/pages/ChangeMyPasswordPage'
|
||||||
import { RolesPage } from './features/roles/pages/RolesPage'
|
import { RolesPage } from './features/roles/pages/RolesPage'
|
||||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||||
@@ -19,9 +24,30 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a protected route with ProtectedLayout + MustChangePasswordGate.
|
||||||
|
* The gate forces users with mustChangePassword=true to /perfil/contrasena.
|
||||||
|
*/
|
||||||
|
function ProtectedPage({
|
||||||
|
children,
|
||||||
|
requiredPermissions,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
requiredPermissions?: string[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute requiredPermissions={requiredPermissions}>
|
||||||
|
<MustChangePasswordGate>
|
||||||
|
<ProtectedLayout>{children}</ProtectedLayout>
|
||||||
|
</MustChangePasswordGate>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
@@ -32,71 +58,102 @@ export function AppRoutes() {
|
|||||||
</PublicRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Change password — protected but NO MustChangePasswordGate (avoids redirect loop) */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/perfil/contrasena"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<ProtectedLayout>
|
<ProtectedLayout>
|
||||||
<HomePage />
|
<ChangeMyPasswordPage />
|
||||||
</ProtectedLayout>
|
</ProtectedLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Protected routes — all wrapped with MustChangePasswordGate */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<ProtectedPage><HomePage /></ProtectedPage>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/usuarios"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||||
|
<UsersListPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/usuarios/nuevo"
|
path="/usuarios/nuevo"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||||
<ProtectedLayout>
|
<CreateUserPage />
|
||||||
<CreateUserPage />
|
</ProtectedPage>
|
||||||
</ProtectedLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/usuarios/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||||
|
<UserDetailPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/usuarios/:id/editar"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||||
|
<UserEditPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/roles"
|
path="/admin/roles"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<RolesPage />
|
||||||
<RolesPage />
|
</ProtectedPage>
|
||||||
</ProtectedLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/roles/nuevo"
|
path="/admin/roles/nuevo"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<NewRolPage />
|
||||||
<NewRolPage />
|
</ProtectedPage>
|
||||||
</ProtectedLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/roles/:codigo/editar"
|
path="/admin/roles/:codigo/editar"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
|
||||||
<ProtectedLayout>
|
<EditRolPage />
|
||||||
<EditRolPage />
|
</ProtectedPage>
|
||||||
</ProtectedLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/permisos"
|
path="/admin/permisos"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute
|
<ProtectedPage
|
||||||
requiredPermissions={[
|
requiredPermissions={[
|
||||||
'administracion:roles_permisos:gestionar',
|
'administracion:roles_permisos:gestionar',
|
||||||
'administracion:permisos:ver',
|
'administracion:permisos:ver',
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ProtectedLayout>
|
<RolPermisosPage />
|
||||||
<RolPermisosPage />
|
</ProtectedPage>
|
||||||
</ProtectedLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
|||||||
nombre: string
|
nombre: string
|
||||||
rol: string
|
rol: string
|
||||||
permisos: string[]
|
permisos: string[]
|
||||||
|
mustChangePassword: boolean // UDT-008
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetAuthPayload {
|
interface SetAuthPayload {
|
||||||
@@ -22,6 +23,7 @@ interface AuthState {
|
|||||||
refreshToken: string | null
|
refreshToken: string | null
|
||||||
expiresAt: number | null // ms epoch UTC
|
expiresAt: number | null // ms epoch UTC
|
||||||
setAuth: (payload: SetAuthPayload) => void
|
setAuth: (payload: SetAuthPayload) => void
|
||||||
|
updateUser: (patch: Partial<AuthUser>) => void // UDT-008
|
||||||
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
|
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
|
||||||
clearAuth: () => void
|
clearAuth: () => void
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
@@ -43,6 +45,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
expiresAt: Date.now() + payload.expiresIn * 1000,
|
expiresAt: Date.now() + payload.expiresIn * 1000,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateUser: (patch) =>
|
||||||
|
set((s) => ({
|
||||||
|
user: s.user ? { ...s.user, ...patch } : null,
|
||||||
|
})),
|
||||||
|
|
||||||
updateAccess: (accessToken, refreshToken, expiresAt) =>
|
updateAccess: (accessToken, refreshToken, expiresAt) =>
|
||||||
set({ accessToken, refreshToken, expiresAt }),
|
set({ accessToken, refreshToken, expiresAt }),
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
function setAuth(accessToken: string, refreshToken: string) {
|
function setAuth(accessToken: string, refreshToken: string) {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [], mustChangePassword: false },
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
expiresAt: Date.now() + 3600 * 1000,
|
expiresAt: Date.now() + 3600 * 1000,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
import { MustChangePasswordGate } from '../../../components/routing/MustChangePasswordGate'
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderGate(initialPath: string, mustChangePassword: boolean | null) {
|
||||||
|
if (mustChangePassword !== null) {
|
||||||
|
useAuthStore.setState({ user: { ...adminUser, mustChangePassword } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/perfil/contrasena" element={<div>Change Password Page</div>} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<MustChangePasswordGate>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</MustChangePasswordGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MustChangePasswordGate', () => {
|
||||||
|
it('redirects to /perfil/contrasena when mustChangePassword=true and on different route', () => {
|
||||||
|
renderGate('/usuarios', true)
|
||||||
|
|
||||||
|
expect(screen.getByText('Change Password Page')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to /perfil/contrasena when mustChangePassword=true on root', () => {
|
||||||
|
renderGate('/', true)
|
||||||
|
|
||||||
|
expect(screen.getByText('Change Password Page')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders children when mustChangePassword=false', () => {
|
||||||
|
renderGate('/usuarios', false)
|
||||||
|
|
||||||
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Change Password Page')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders children when user is null (let ProtectedRoute handle auth)', () => {
|
||||||
|
// user is null — gate should pass through, ProtectedRoute will handle it
|
||||||
|
renderGate('/usuarios', null)
|
||||||
|
|
||||||
|
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows render on /perfil/contrasena when mustChangePassword=true (no redirect loop)', () => {
|
||||||
|
useAuthStore.setState({ user: { ...adminUser, mustChangePassword: true } })
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/perfil/contrasena']}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/perfil/contrasena"
|
||||||
|
element={
|
||||||
|
<MustChangePasswordGate>
|
||||||
|
<div>Change Password Page Content</div>
|
||||||
|
</MustChangePasswordGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Change Password Page Content')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ describe('CanPerform', () => {
|
|||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar'],
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ describe('CanPerform', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ describe('CanPerform', () => {
|
|||||||
nombre: 'Reportes',
|
nombre: 'Reportes',
|
||||||
rol: 'reportes',
|
rol: 'reportes',
|
||||||
permisos: [],
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ describe('CanPerform', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const mockLoginResponse = {
|
|||||||
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
|
||||||
refreshToken: 'refresh-token-abc',
|
refreshToken: 'refresh-token-abc',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] },
|
usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false },
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('ProtectedRoute', () => {
|
|||||||
|
|
||||||
it('F-03-02: user autenticado sin restricciones → renderiza children', () => {
|
it('F-03-02: user autenticado sin restricciones → renderiza children', () => {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
|
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -101,6 +101,7 @@ describe('ProtectedRoute', () => {
|
|||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar'],
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ describe('ProtectedRoute', () => {
|
|||||||
|
|
||||||
it('F-03-04: requiredRoles no coincide → redirect a /', () => {
|
it('F-03-04: requiredRoles no coincide → redirect a /', () => {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
|
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -158,6 +159,7 @@ describe('ProtectedRoute', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,6 +193,7 @@ describe('ProtectedRoute', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,6 +226,7 @@ describe('ProtectedRoute', () => {
|
|||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar'],
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,6 +258,7 @@ describe('ProtectedRoute', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const mockLoginResponseWithPermisos = {
|
|||||||
nombre: 'Admin Sistema',
|
nombre: 'Admin Sistema',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
|
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,21 @@ const mockLoginResponseEmptyPermisos = {
|
|||||||
nombre: 'Cajero Test',
|
nombre: 'Cajero Test',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: [],
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLoginResponseMustChange = {
|
||||||
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
|
||||||
|
refreshToken: 'refresh-token-abc',
|
||||||
|
expiresIn: 3600,
|
||||||
|
usuario: {
|
||||||
|
id: 3,
|
||||||
|
username: 'newuser',
|
||||||
|
nombre: 'New User',
|
||||||
|
rol: 'cajero',
|
||||||
|
permisos: [],
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,3 +110,44 @@ describe('useLogin — permisos propagation', () => {
|
|||||||
expect(state.user?.permisos).not.toBeNull()
|
expect(state.user?.permisos).not.toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useLogin — mustChangePassword propagation', () => {
|
||||||
|
it('F-login-03: persists mustChangePassword=false from login response', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/auth/login`, () =>
|
||||||
|
HttpResponse.json(mockLoginResponseWithPermisos, { status: 200 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({ username: 'admin', password: 'password' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F-login-04: persists mustChangePassword=true from login response', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/auth/login`, () =>
|
||||||
|
HttpResponse.json(mockLoginResponseMustChange, { status: 200 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({ username: 'newuser', password: 'password' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(true)
|
||||||
|
expect(state.user?.username).toBe('newuser')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('usePermission', () => {
|
|||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar'],
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ describe('usePermission', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe('usePermission', () => {
|
|||||||
nombre: 'Reportes',
|
nombre: 'Reportes',
|
||||||
rol: 'reportes',
|
rol: 'reportes',
|
||||||
permisos: [],
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ describe('usePermission', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ describe('usePermission', () => {
|
|||||||
nombre: 'Cajero',
|
nombre: 'Cajero',
|
||||||
rol: 'cajero',
|
rol: 'cajero',
|
||||||
permisos: ['ventas:contado:crear'],
|
permisos: ['ventas:contado:crear'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
139
src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx
Normal file
139
src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { ChangeMyPasswordPage } from '../../../features/profile/pages/ChangeMyPasswordPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
const authUser = {
|
||||||
|
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||||
|
permisos: [], mustChangePassword: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
useAuthStore.setState({ user: authUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/perfil/contrasena']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/perfil/contrasena" element={<ChangeMyPasswordPage />} />
|
||||||
|
<Route path="/" element={<div>Home</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: get form fields by their input id
|
||||||
|
function getOldPasswordInput() { return screen.getByLabelText('Contraseña actual') }
|
||||||
|
function getNewPasswordInput() { return screen.getByLabelText('Nueva contraseña') }
|
||||||
|
function getConfirmPasswordInput() { return screen.getByLabelText('Confirmar nueva contraseña') }
|
||||||
|
function getSubmitButton() { return screen.getByRole('button', { name: /cambiar contraseña/i }) }
|
||||||
|
|
||||||
|
describe('ChangeMyPasswordPage', () => {
|
||||||
|
it('shows validation error when passwords do not match', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/users/me/password`, () => {
|
||||||
|
throw new Error('Should not be called')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(getOldPasswordInput(), 'current123')
|
||||||
|
await userEvent.type(getNewPasswordInput(), 'NewPass123')
|
||||||
|
await userEvent.type(getConfirmPasswordInput(), 'DifferentPass456')
|
||||||
|
|
||||||
|
await userEvent.click(getSubmitButton())
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/no coinciden/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no HTTP call when passwords do not match', async () => {
|
||||||
|
let httpCalled = false
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/users/me/password`, () => {
|
||||||
|
httpCalled = true
|
||||||
|
return HttpResponse.json({}, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(getOldPasswordInput(), 'current123')
|
||||||
|
await userEvent.type(getNewPasswordInput(), 'NewPass123')
|
||||||
|
await userEvent.type(getConfirmPasswordInput(), 'WrongConfirm')
|
||||||
|
|
||||||
|
await userEvent.click(getSubmitButton())
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
expect(httpCalled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit success → updates authStore mustChangePassword to false + navigate home', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/users/me/password`, () =>
|
||||||
|
new HttpResponse(null, { status: 204 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(getOldPasswordInput(), 'current123')
|
||||||
|
await userEvent.type(getNewPasswordInput(), 'NewPass123')
|
||||||
|
await userEvent.type(getConfirmPasswordInput(), 'NewPass123')
|
||||||
|
|
||||||
|
await userEvent.click(getSubmitButton())
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/'))
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows invalid-old-password error message on 400', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/users/me/password`, () =>
|
||||||
|
HttpResponse.json({ error: 'invalid-old-password' }, { status: 400 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(getOldPasswordInput(), 'wrongpassword')
|
||||||
|
await userEvent.type(getNewPasswordInput(), 'NewPass123')
|
||||||
|
await userEvent.type(getConfirmPasswordInput(), 'NewPass123')
|
||||||
|
|
||||||
|
await userEvent.click(getSubmitButton())
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/contraseña actual es incorrecta/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
117
src/web/src/tests/features/users/ResetPasswordModal.test.tsx
Normal file
117
src/web/src/tests/features/users/ResetPasswordModal.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { ResetPasswordModal } from '../../../features/users/components/ResetPasswordModal'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderModal(userId = 5) {
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<ResetPasswordModal userId={userId} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResetPasswordModal', () => {
|
||||||
|
it('shows trigger button and modal closed by default', () => {
|
||||||
|
renderModal()
|
||||||
|
expect(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/confirmar|advertencia|única vez/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trigger button → modal opens with confirmation', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /resetear contraseña/i }))
|
||||||
|
|
||||||
|
// Modal should now show the confirm button
|
||||||
|
expect(screen.getByRole('button', { name: /confirmar/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /cancelar/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel → modal closes without HTTP call', async () => {
|
||||||
|
let httpCalled = false
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/users/5/password/reset`, () => {
|
||||||
|
httpCalled = true
|
||||||
|
return HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar|cancel/i }))
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
expect(httpCalled).toBe(false)
|
||||||
|
expect(screen.queryByText(/contraseña temporal|tempPassword/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm → calls POST and shows tempPassword + warning', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/users/5/password/reset`, () =>
|
||||||
|
HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText(/única vez|solo una vez|this is the only time/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copy button calls clipboard.writeText with tempPassword', async () => {
|
||||||
|
const clipboardWriteText = vi.fn().mockResolvedValue(undefined)
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: clipboardWriteText },
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/users/5/password/reset`, () =>
|
||||||
|
HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument())
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /copiar|copy/i }))
|
||||||
|
|
||||||
|
expect(clipboardWriteText).toHaveBeenCalledWith('Ax!k9mQ3@rT2')
|
||||||
|
})
|
||||||
|
})
|
||||||
78
src/web/src/tests/features/users/UserDetailPage.test.tsx
Normal file
78
src/web/src/tests/features/users/UserDetailPage.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { UserDetailPage } from '../../../features/users/pages/UserDetailPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
id: 5,
|
||||||
|
username: 'cajero1',
|
||||||
|
nombre: 'Juan',
|
||||||
|
apellido: 'Perez',
|
||||||
|
email: 'juan@test.com',
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
permisosJson: '[]',
|
||||||
|
fechaModificacion: null,
|
||||||
|
ultimoLogin: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderDetail(userId: number) {
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[`/usuarios/${userId}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/usuarios/:id" element={<UserDetailPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserDetailPage — reset password wiring', () => {
|
||||||
|
it('shows "Resetear contraseña" button when viewing another user', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(target)),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderDetail(5)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Juan Perez')).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: /resetear contraseña/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Resetear contraseña" button when viewing own profile (prevent cannot-self-reset)', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/1`, () =>
|
||||||
|
HttpResponse.json({ ...target, id: 1, username: 'admin', nombre: 'Admin', apellido: 'Root', rol: 'admin' }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderDetail(1)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Admin Root')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
165
src/web/src/tests/features/users/UserEditPage.test.tsx
Normal file
165
src/web/src/tests/features/users/UserEditPage.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { UserEditPage } from '../../../features/users/pages/UserEditPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUserDetail = {
|
||||||
|
id: 5,
|
||||||
|
username: 'cajero1',
|
||||||
|
nombre: 'Juan',
|
||||||
|
apellido: 'Pérez',
|
||||||
|
email: 'j@x.com',
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
ultimoLogin: null,
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderEditPage(userId = 5) {
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[`/usuarios/${userId}/editar`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/usuarios/:id/editar" element={<UserEditPage />} />
|
||||||
|
<Route path="/usuarios" element={<div>Users List</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserEditPage', () => {
|
||||||
|
it('prefills form with user data', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||||
|
expect(screen.getByDisplayValue('Pérez')).toBeInTheDocument()
|
||||||
|
expect(screen.getByDisplayValue('j@x.com')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('username field is displayed but not an editable input', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('cajero1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Username should NOT be an editable input
|
||||||
|
const inputs = screen.queryAllByRole('textbox')
|
||||||
|
const usernameInput = inputs.find((el) => (el as HTMLInputElement).value === 'cajero1')
|
||||||
|
expect(usernameInput).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls PUT with correct payload then navigates to /usuarios', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||||
|
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json({ ...mockUserDetail, nombre: 'Pedro' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Clear and update nombre
|
||||||
|
const nombreInput = screen.getByDisplayValue('Juan')
|
||||||
|
await userEvent.clear(nombreInput)
|
||||||
|
await userEvent.type(nombreInput, 'Pedro')
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/usuarios'))
|
||||||
|
expect(capturedBody).toMatchObject({ nombre: 'Pedro' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows last-admin-lockout error message on 400', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||||
|
http.put(`${API_URL}/api/v1/users/5`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ title: 'last-admin-lockout', status: 400 },
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/último administrador|last.admin.lockout/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should NOT navigate
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Resetear contraseña" button when editing another user', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage(5)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: /resetear contraseña/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Resetear contraseña" button when editing own profile', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users/1`, () =>
|
||||||
|
HttpResponse.json({ ...mockUserDetail, id: 1, username: 'admin' }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditPage(1)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
227
src/web/src/tests/features/users/UsersListPage.test.tsx
Normal file
227
src/web/src/tests/features/users/UsersListPage.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { UsersListPage } from '../../../features/users/pages/UsersListPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItems(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
username: `user${i + 1}`,
|
||||||
|
nombre: `Nombre${i + 1}`,
|
||||||
|
apellido: `Apellido${i + 1}`,
|
||||||
|
email: `user${i + 1}@test.com`,
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
ultimoLogin: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/usuarios']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/usuarios" element={<UsersListPage />} />
|
||||||
|
<Route path="/usuarios/:id/editar" element={<div>Edit Page</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UsersListPage', () => {
|
||||||
|
it('renders 5 rows when API returns 5 items', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
// All 5 usernames visible
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
expect(screen.getByText(`user${i}`)).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when items is empty', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/sin resultados|no se encontraron/i)).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev button disabled on first page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const prevBtn = screen.getByRole('button', { name: /anterior|prev/i })
|
||||||
|
expect(prevBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next button disabled when on last page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
|
||||||
|
expect(nextBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next button enabled when more pages exist, click requests page 2', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = parseInt(url.searchParams.get('page') ?? '1')
|
||||||
|
return HttpResponse.json({
|
||||||
|
items: makeItems(3),
|
||||||
|
page,
|
||||||
|
pageSize: 3,
|
||||||
|
total: 6,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
useAuthStore.setState({ user: adminUser })
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<UsersListPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
|
||||||
|
expect(nextBtn).not.toBeDisabled()
|
||||||
|
|
||||||
|
await userEvent.click(nextBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const page2Req = requests.find((u) => u.includes('page=2'))
|
||||||
|
expect(page2Req).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting rol filter adds querystring rol', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||||
|
|
||||||
|
const rolSelect = screen.getByRole('combobox', { name: /rol/i })
|
||||||
|
await userEvent.selectOptions(rolSelect, 'admin')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const filtered = requests.find((u) => u.includes('rol=admin'))
|
||||||
|
expect(filtered).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('typing in search input triggers request with search param (debounced)', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/buscar/i)
|
||||||
|
// Use fireEvent to type quickly without delay — then wait for debounce naturally
|
||||||
|
await userEvent.type(searchInput, 'juan')
|
||||||
|
|
||||||
|
// After debounce (300ms + render cycles), should include search param
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
const searched = requests.find((u) => u.includes('search='))
|
||||||
|
expect(searched).toBeTruthy()
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
)
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
it('click row navigates to edit page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: makeItems(2), page: 1, pageSize: 20, total: 2 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
// Wait for data to load
|
||||||
|
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Click on the username cell which is inside the row
|
||||||
|
const usernameCell = screen.getByText('user1')
|
||||||
|
await userEvent.click(usernameCell)
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/usuarios/1/editar')
|
||||||
|
})
|
||||||
|
})
|
||||||
35
src/web/src/tests/features/users/getUser.test.ts
Normal file
35
src/web/src/tests/features/users/getUser.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { getUser } from '../../../features/users/api/getUser'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockDetail = {
|
||||||
|
id: 5,
|
||||||
|
username: 'cajero1',
|
||||||
|
nombre: 'Juan',
|
||||||
|
apellido: 'Pérez',
|
||||||
|
email: 'j@x.com',
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
ultimoLogin: '2026-04-10T10:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('getUser api client', () => {
|
||||||
|
it('calls GET /api/v1/users/:id and returns UserDetail', async () => {
|
||||||
|
server.use(http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockDetail)))
|
||||||
|
const result = await getUser(5)
|
||||||
|
expect(result.id).toBe(5)
|
||||||
|
expect(result.username).toBe('cajero1')
|
||||||
|
expect(result.mustChangePassword).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
70
src/web/src/tests/features/users/listUsers.test.ts
Normal file
70
src/web/src/tests/features/users/listUsers.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { listUsers } from '../../../features/users/api/listUsers'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockPage1 = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'Sistema', email: null, rol: 'admin', activo: true, ultimoLogin: null },
|
||||||
|
{ id: 2, username: 'cajero1', nombre: 'Juan', apellido: 'Pérez', email: 'j@x.com', rol: 'cajero', activo: true, ultimoLogin: '2026-04-10T10:00:00Z' },
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('listUsers api client', () => {
|
||||||
|
it('calls GET /api/v1/users and returns PagedResult', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockPage1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await listUsers({})
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.page).toBe(1)
|
||||||
|
expect(result.pageSize).toBe(20)
|
||||||
|
expect(result.total).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes query params: page, pageSize, rol, activo, search', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await listUsers({ page: 2, pageSize: 10, rol: 'cajero', activo: false, search: 'juan' })
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
expect(capturedUrl).toContain('rol=cajero')
|
||||||
|
expect(capturedUrl).toContain('activo=false')
|
||||||
|
expect(capturedUrl).toContain('search=juan')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits undefined params from querystring', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await listUsers({ page: 1 })
|
||||||
|
|
||||||
|
expect(capturedUrl).not.toContain('rol=')
|
||||||
|
expect(capturedUrl).not.toContain('activo=')
|
||||||
|
expect(capturedUrl).not.toContain('search=')
|
||||||
|
})
|
||||||
|
})
|
||||||
48
src/web/src/tests/features/users/updateUser.test.ts
Normal file
48
src/web/src/tests/features/users/updateUser.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { updateUser } from '../../../features/users/api/updateUser'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockDetail = {
|
||||||
|
id: 5,
|
||||||
|
username: 'cajero1',
|
||||||
|
nombre: 'Pedro',
|
||||||
|
apellido: 'Gómez',
|
||||||
|
email: 'new@x.com',
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
ultimoLogin: null,
|
||||||
|
fechaModificacion: '2026-04-15T18:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('updateUser api client', () => {
|
||||||
|
it('calls PUT /api/v1/users/:id with payload and returns updated UserDetail', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await updateUser(5, {
|
||||||
|
nombre: 'Pedro',
|
||||||
|
apellido: 'Gómez',
|
||||||
|
email: 'new@x.com',
|
||||||
|
rol: 'cajero',
|
||||||
|
activo: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nombre).toBe('Pedro')
|
||||||
|
expect(capturedBody).toMatchObject({ nombre: 'Pedro', apellido: 'Gómez', email: 'new@x.com' })
|
||||||
|
})
|
||||||
|
})
|
||||||
67
src/web/src/tests/features/users/useUsersList.test.ts
Normal file
67
src/web/src/tests/features/users/useUsersList.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import React from 'react'
|
||||||
|
import { useUsersList } from '../../../features/users/hooks/useUsersList'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useUsersList', () => {
|
||||||
|
it('fetches page 1 by default', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, () =>
|
||||||
|
HttpResponse.json({ items: [{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'S', email: null, rol: 'admin', activo: true, ultimoLogin: null }], page: 1, pageSize: 20, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({}), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(result.current.data?.page).toBe(1)
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes rol filter in query string', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({ rol: 'admin' }), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('rol=admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes activo filter', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUsersList({ activo: false }), { wrapper: createWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(capturedUrl).toContain('activo=false')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||||
import { useAuthStore } from '../../stores/authStore'
|
import { useAuthStore } from '../../stores/authStore'
|
||||||
|
|
||||||
|
// Canonical test user fixtures
|
||||||
|
const adminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: [] as string[],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const cajeroUser = {
|
||||||
|
id: 2,
|
||||||
|
username: 'cajero',
|
||||||
|
nombre: 'Cajero',
|
||||||
|
rol: 'cajero',
|
||||||
|
permisos: [] as string[],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
describe('authStore', () => {
|
describe('authStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset store state before each test
|
// Reset store state before each test
|
||||||
@@ -28,7 +47,7 @@ describe('authStore', () => {
|
|||||||
describe('setAuth', () => {
|
describe('setAuth', () => {
|
||||||
it('stores user and accessToken in state', () => {
|
it('stores user and accessToken in state', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
|
||||||
refreshToken: 'opaque-refresh-token',
|
refreshToken: 'opaque-refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -43,7 +62,7 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
it('persists auth data to localStorage under auth-storage key', () => {
|
it('persists auth data to localStorage under auth-storage key', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
|
||||||
refreshToken: 'opaque-refresh-token',
|
refreshToken: 'opaque-refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -61,7 +80,7 @@ describe('authStore', () => {
|
|||||||
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
|
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
|
||||||
const before = Date.now()
|
const before = Date.now()
|
||||||
const payload = {
|
const payload = {
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'access-token-abc',
|
accessToken: 'access-token-abc',
|
||||||
refreshToken: 'opaque-refresh-xyz',
|
refreshToken: 'opaque-refresh-xyz',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -92,6 +111,7 @@ describe('authStore', () => {
|
|||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
|
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
},
|
},
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
@@ -108,7 +128,7 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
it('F-04-02: setAuth con permisos vacíos → user.permisos es [] (no null)', () => {
|
it('F-04-02: setAuth con permisos vacíos → user.permisos es [] (no null)', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
|
user: cajeroUser,
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -120,12 +140,83 @@ describe('authStore', () => {
|
|||||||
expect(state.user?.permisos).toEqual([])
|
expect(state.user?.permisos).toEqual([])
|
||||||
expect(state.user?.permisos).not.toBeNull()
|
expect(state.user?.permisos).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists mustChangePassword=true in state and localStorage', () => {
|
||||||
|
const payload = {
|
||||||
|
user: { ...adminUser, mustChangePassword: true },
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
useAuthStore.getState().setAuth(payload)
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(true)
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('auth-storage')
|
||||||
|
const parsed = JSON.parse(stored!)
|
||||||
|
expect(parsed.state.user.mustChangePassword).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists mustChangePassword=false in state', () => {
|
||||||
|
const payload = {
|
||||||
|
user: { ...adminUser, mustChangePassword: false },
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
useAuthStore.getState().setAuth(payload)
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateUser', () => {
|
||||||
|
it('updateUser_patches_mustChangePassword_preserves_rest', () => {
|
||||||
|
useAuthStore.getState().setAuth({
|
||||||
|
user: { ...adminUser, mustChangePassword: true },
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
})
|
||||||
|
|
||||||
|
useAuthStore.getState().updateUser({ mustChangePassword: false })
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.mustChangePassword).toBe(false)
|
||||||
|
// Other fields preserved
|
||||||
|
expect(state.user?.username).toBe('admin')
|
||||||
|
expect(state.user?.rol).toBe('admin')
|
||||||
|
expect(state.user?.id).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateUser_noops_when_user_null', () => {
|
||||||
|
// user is null — should not throw
|
||||||
|
expect(() => useAuthStore.getState().updateUser({ mustChangePassword: false })).not.toThrow()
|
||||||
|
expect(useAuthStore.getState().user).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateUser_can_patch_username', () => {
|
||||||
|
useAuthStore.getState().setAuth({
|
||||||
|
user: adminUser,
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
})
|
||||||
|
|
||||||
|
useAuthStore.getState().updateUser({ username: 'new-admin' })
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().user?.username).toBe('new-admin')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('clearAuth', () => {
|
describe('clearAuth', () => {
|
||||||
it('F-04-03: clearAuth → user = null (permisos se limpian con el user)', () => {
|
it('F-04-03: clearAuth → user = null (permisos se limpian con el user)', () => {
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] },
|
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false },
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -139,7 +230,7 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
it('clearAuth_removesAllFields', () => {
|
it('clearAuth_removesAllFields', () => {
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -157,9 +248,8 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
describe('updateAccess', () => {
|
describe('updateAccess', () => {
|
||||||
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
|
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
|
||||||
const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }
|
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: originalUser,
|
user: adminUser,
|
||||||
accessToken: 'old-access',
|
accessToken: 'old-access',
|
||||||
refreshToken: 'old-refresh',
|
refreshToken: 'old-refresh',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -173,7 +263,7 @@ describe('authStore', () => {
|
|||||||
expect(state.refreshToken).toBe('new-refresh')
|
expect(state.refreshToken).toBe('new-refresh')
|
||||||
expect(state.expiresAt).toBe(newExpiresAt)
|
expect(state.expiresAt).toBe(newExpiresAt)
|
||||||
// user should be preserved
|
// user should be preserved
|
||||||
expect(state.user).toEqual(originalUser)
|
expect(state.user).toEqual(adminUser)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -181,7 +271,7 @@ describe('authStore', () => {
|
|||||||
it('logout_callsApi_thenClearsAuth', async () => {
|
it('logout_callsApi_thenClearsAuth', async () => {
|
||||||
// Set up auth state with a token so logout() will try to call the API
|
// Set up auth state with a token so logout() will try to call the API
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -201,14 +291,13 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
it('logout_apiFails_stillClearsAuth', async () => {
|
it('logout_apiFails_stillClearsAuth', async () => {
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'access-token',
|
accessToken: 'access-token',
|
||||||
refreshToken: 'refresh-token',
|
refreshToken: 'refresh-token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Should NOT throw even if the dynamic import fails
|
// Should NOT throw even if the dynamic import fails
|
||||||
// (We test this by verifying clearAuth is always called)
|
|
||||||
let threw = false
|
let threw = false
|
||||||
try {
|
try {
|
||||||
await useAuthStore.getState().logout()
|
await useAuthStore.getState().logout()
|
||||||
@@ -226,7 +315,7 @@ describe('authStore', () => {
|
|||||||
describe('legacy logout compatibility (via clearAuth)', () => {
|
describe('legacy logout compatibility (via clearAuth)', () => {
|
||||||
it('clearAuth clears user and accessToken from state', () => {
|
it('clearAuth clears user and accessToken from state', () => {
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'some-token',
|
accessToken: 'some-token',
|
||||||
refreshToken: 'some-refresh',
|
refreshToken: 'some-refresh',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
@@ -241,7 +330,7 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
it('clearAuth removes auth-storage from localStorage', () => {
|
it('clearAuth removes auth-storage from localStorage', () => {
|
||||||
useAuthStore.getState().setAuth({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
|
user: adminUser,
|
||||||
accessToken: 'some-token',
|
accessToken: 'some-token',
|
||||||
refreshToken: 'some-refresh',
|
refreshToken: 'some-refresh',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
|
|||||||
130
tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs
Normal file
130
tests/SIGCM2.Api.Tests/Usuarios/ChangeMyPasswordEndpointTests.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Usuarios;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for PUT /api/v1/users/me/password (UDT-008 B6).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class ChangeMyPasswordEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
// This hash corresponds to "@Diego550@"
|
||||||
|
private const string DefaultHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
|
||||||
|
public ChangeMyPasswordEndpointTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
_db = new SqlTestFixture(TestConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _db.InitializeAsync();
|
||||||
|
public async Task DisposeAsync() => await _db.DisposeAsync();
|
||||||
|
|
||||||
|
private async Task<int> SeedUserAsync(string username)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
return await conn.ExecuteScalarAsync<int>($"""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}')
|
||||||
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||||
|
VALUES ('{username}', '{DefaultHash}', 'Test', 'User', 'cajero', '[]', 1, 0);
|
||||||
|
SELECT Id FROM dbo.Usuario WHERE Username = '{username}'
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetTokenAsync(string username)
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username, password = "@Diego550@" });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Me_Password_204_No_Content()
|
||||||
|
{
|
||||||
|
await SeedUserAsync("user_chpwd_happy");
|
||||||
|
var token = await GetTokenAsync("user_chpwd_happy");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Me_Password_400_Wrong_Old_With_Error_Key()
|
||||||
|
{
|
||||||
|
await SeedUserAsync("user_chpwd_wrongold");
|
||||||
|
var token = await GetTokenAsync("user_chpwd_wrongold");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { oldPassword = "WrongPassword!", newPassword = "Nuevo1234!" });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("invalid-old-password", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Me_Password_400_Weak_New_Password()
|
||||||
|
{
|
||||||
|
await SeedUserAsync("user_chpwd_weak");
|
||||||
|
var token = await GetTokenAsync("user_chpwd_weak");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "abc" }); // too short
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Me_Password_401_No_Auth()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password");
|
||||||
|
request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Me_Password_Does_NOT_Require_Users_Manage_Permission()
|
||||||
|
{
|
||||||
|
// Cajero user (no users:gestionar permission) should be able to change own password
|
||||||
|
await SeedUserAsync("cajero_chpwd");
|
||||||
|
var token = await GetTokenAsync("cajero_chpwd");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/me/password");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { oldPassword = "@Diego550@", newPassword = "Nuevo1234!" });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
// Should succeed with 204, NOT 403
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Usuarios;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for PATCH /api/v1/users/{id}/deactivate and /reactivate (UDT-008 B5).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class DeactivateReactivateEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
|
||||||
|
public DeactivateReactivateEndpointTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
_db = new SqlTestFixture(TestConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _db.InitializeAsync();
|
||||||
|
public async Task DisposeAsync() => await _db.DisposeAsync();
|
||||||
|
|
||||||
|
private async Task<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<int> SeedCajeroAsync(string username, bool activo = true)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
var activoVal = activo ? 1 : 0;
|
||||||
|
return await conn.ExecuteScalarAsync<int>($"""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}')
|
||||||
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||||
|
VALUES ('{username}', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Test', 'Usuario', 'cajero', '[]', {activoVal}, 0);
|
||||||
|
SELECT Id FROM dbo.Usuario WHERE Username = '{username}'
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCajeroTokenAsync()
|
||||||
|
{
|
||||||
|
await SeedCajeroAsync("cajero_deact_auth");
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "cajero_deact_auth", password = "@Diego550@" });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── deactivate ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_200_Returns_UserDetail_Activo_False()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_deact_happy", true);
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/deactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.False(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_Idempotent_Returns_200()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_deact_idempotent", false); // already inactive
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/deactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.False(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_400_Last_Admin_Lockout()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{adminId}/deactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("last-admin-lockout", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_404_Not_Found()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/9999/deactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_401_No_Auth()
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/deactivate"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Deactivate_403_No_Permission()
|
||||||
|
{
|
||||||
|
var token = await GetCajeroTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/deactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reactivate ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Reactivate_200_Returns_UserDetail_Activo_True()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_react_happy", false); // inactive
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/reactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Reactivate_Idempotent_Returns_200()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_react_idempotent", true); // already active
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/users/{targetId}/reactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Reactivate_404_Not_Found()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/9999/reactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Reactivate_401_No_Auth()
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/reactivate"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PATCH_Reactivate_403_No_Permission()
|
||||||
|
{
|
||||||
|
var token = await GetCajeroTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/users/1/reactivate");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs
Normal file
153
tests/SIGCM2.Api.Tests/Usuarios/ResetPasswordEndpointTests.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Usuarios;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for POST /api/v1/users/{id}/password/reset (UDT-008 B7).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class ResetPasswordEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
|
||||||
|
public ResetPasswordEndpointTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
_db = new SqlTestFixture(TestConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _db.InitializeAsync();
|
||||||
|
public async Task DisposeAsync() => await _db.DisposeAsync();
|
||||||
|
|
||||||
|
private async Task<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<int> SeedCajeroAsync(string username)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
return await conn.ExecuteScalarAsync<int>($"""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}')
|
||||||
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||||
|
VALUES ('{username}', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Test', 'User', 'cajero', '[]', 1, 0);
|
||||||
|
SELECT Id FROM dbo.Usuario WHERE Username = '{username}'
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCajeroTokenAsync()
|
||||||
|
{
|
||||||
|
await SeedCajeroAsync("cajero_reset_auth");
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "cajero_reset_auth", password = "@Diego550@" });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_200_Returns_TempPassword()
|
||||||
|
{
|
||||||
|
var adminToken = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_reset_happy");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("tempPassword", out var tempProp));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(tempProp.GetString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_TempPassword_Length_Gte_12()
|
||||||
|
{
|
||||||
|
var adminToken = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_reset_length");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var tempPassword = json.GetProperty("tempPassword").GetString()!;
|
||||||
|
Assert.True(tempPassword.Length >= 12, $"TempPassword too short: {tempPassword.Length}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_400_Cannot_Self_Reset()
|
||||||
|
{
|
||||||
|
var adminToken = await GetAdminTokenAsync();
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{adminId}/password/reset");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("cannot-self-reset", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_404_Target_Not_Found()
|
||||||
|
{
|
||||||
|
var adminToken = await GetAdminTokenAsync();
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/users/9999/password/reset");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_401_No_Auth()
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/api/v1/users/1/password/reset"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task POST_Password_Reset_403_No_Permission()
|
||||||
|
{
|
||||||
|
var cajeroToken = await GetCajeroTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_reset_403_target");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/users/{targetId}/password/reset");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cajeroToken);
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs
Normal file
155
tests/SIGCM2.Api.Tests/Usuarios/UpdateUsuarioEndpointTests.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Usuarios;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for PUT /api/v1/users/{id} (UDT-008 B4).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class UpdateUsuarioEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
|
||||||
|
public UpdateUsuarioEndpointTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
_db = new SqlTestFixture(TestConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _db.InitializeAsync();
|
||||||
|
public async Task DisposeAsync() => await _db.DisposeAsync();
|
||||||
|
|
||||||
|
private async Task<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<int> SeedCajeroAsync(string username = "cajero_update")
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
return await conn.ExecuteScalarAsync<int>($"""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = '{username}')
|
||||||
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
|
||||||
|
VALUES ('{username}', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Test', 'Usuario', 'cajero', '[]', 1, 0);
|
||||||
|
SELECT Id FROM dbo.Usuario WHERE Username = '{username}'
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCajeroTokenAsync()
|
||||||
|
{
|
||||||
|
var id = await SeedCajeroAsync("cajero_update_auth");
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
|
||||||
|
new { username = "cajero_update_auth", password = "@Diego550@" });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_200_Returns_Updated_Detail()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_upd_happy");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{targetId}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "Editado", apellido = "Test", email = (string?)null, rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("Editado", json.GetProperty("nombre").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_400_Invalid_Email()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var targetId = await SeedCajeroAsync("cajero_upd_email");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{targetId}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = "not-an-email", rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_400_Last_Admin_Lockout_With_Error_Key()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var adminId = await GetAdminIdAsync();
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "Admin", apellido = "Sys", email = (string?)null, rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("last-admin-lockout", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_404_Not_Found()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/9999");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_403_No_Permission()
|
||||||
|
{
|
||||||
|
var token = await GetCajeroTokenAsync();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/1");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PUT_Users_Id_401_No_Auth()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/users/1");
|
||||||
|
request.Content = JsonContent.Create(new { nombre = "A", apellido = "B", email = (string?)null, rol = "cajero", activo = true });
|
||||||
|
|
||||||
|
var response = await _client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
@@ -19,6 +20,7 @@ public class LoginCommandHandlerTests
|
|||||||
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
||||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||||
|
private readonly ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
|
||||||
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
||||||
private readonly LoginCommandHandler _handler;
|
private readonly LoginCommandHandler _handler;
|
||||||
|
|
||||||
@@ -29,6 +31,10 @@ public class LoginCommandHandlerTests
|
|||||||
_refreshGenerator.Generate().Returns("raw_refresh_token_value");
|
_refreshGenerator.Generate().Returns("raw_refresh_token_value");
|
||||||
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1);
|
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1);
|
||||||
|
|
||||||
|
// Default: UpdateUltimoLoginAsync succeeds silently
|
||||||
|
_repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
// Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben
|
// Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben
|
||||||
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new List<Permiso>().AsReadOnly());
|
.Returns(new List<Permiso>().AsReadOnly());
|
||||||
@@ -36,7 +42,7 @@ public class LoginCommandHandlerTests
|
|||||||
_handler = new LoginCommandHandler(
|
_handler = new LoginCommandHandler(
|
||||||
_repository, _hasher, _jwtService,
|
_repository, _hasher, _jwtService,
|
||||||
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
||||||
_rolPermisoRepo);
|
_rolPermisoRepo, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: valid credentials → returns token response with usuario populated
|
// Scenario: valid credentials → returns token response with usuario populated
|
||||||
@@ -243,4 +249,78 @@ public class LoginCommandHandlerTests
|
|||||||
t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
|
t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
|
||||||
t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
|
t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: username + mustChangePassword + UltimoLogin ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PopulatesUsername_InUsuarioDto()
|
||||||
|
{
|
||||||
|
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
|
||||||
|
_repository.GetByUsernameAsync("jperez").Returns(usuario);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new LoginCommand("jperez", "pass"));
|
||||||
|
|
||||||
|
Assert.Equal("jperez", result.Usuario.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PopulatesMustChangePassword_False_WhenZero()
|
||||||
|
{
|
||||||
|
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true,
|
||||||
|
mustChangePassword: false);
|
||||||
|
_repository.GetByUsernameAsync("jperez").Returns(usuario);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new LoginCommand("jperez", "pass"));
|
||||||
|
|
||||||
|
Assert.False(result.Usuario.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PopulatesMustChangePassword_True_WhenSet()
|
||||||
|
{
|
||||||
|
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true,
|
||||||
|
mustChangePassword: true);
|
||||||
|
_repository.GetByUsernameAsync("jperez").Returns(usuario);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new LoginCommand("jperez", "pass"));
|
||||||
|
|
||||||
|
Assert.True(result.Usuario.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_CallsUpdateUltimoLoginAsync_AfterSuccessfulAuth()
|
||||||
|
{
|
||||||
|
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
|
||||||
|
_repository.GetByUsernameAsync("jperez").Returns(usuario);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
||||||
|
|
||||||
|
await _handler.Handle(new LoginCommand("jperez", "pass"));
|
||||||
|
|
||||||
|
await _repository.Received(1).UpdateUltimoLoginAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Succeeds_EvenIf_UpdateUltimoLogin_Throws()
|
||||||
|
{
|
||||||
|
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
|
||||||
|
_repository.GetByUsernameAsync("jperez").Returns(usuario);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
||||||
|
|
||||||
|
// Simulate DB hiccup on UltimoLogin update
|
||||||
|
_repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromException(new Exception("DB timeout")));
|
||||||
|
|
||||||
|
// Login must still succeed
|
||||||
|
var result = await _handler.Handle(new LoginCommand("jperez", "pass"));
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.AccessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Common;
|
||||||
|
|
||||||
|
public class TempPasswordGeneratorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Default_Length_Is_12()
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate();
|
||||||
|
Assert.Equal(12, pwd.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Always_Has_Uppercase_Letter()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate();
|
||||||
|
Assert.True(pwd.Any(char.IsUpper), $"No uppercase found in: {pwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Always_Has_Lowercase_Letter()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate();
|
||||||
|
Assert.True(pwd.Any(char.IsLower), $"No lowercase found in: {pwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Always_Has_Digit()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate();
|
||||||
|
Assert.True(pwd.Any(char.IsDigit), $"No digit found in: {pwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Always_Has_Special_Char()
|
||||||
|
{
|
||||||
|
const string symbols = "!@#$%&*+-=?";
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate();
|
||||||
|
Assert.True(pwd.Any(c => symbols.Contains(c)), $"No symbol found in: {pwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Below_8_Throws_ArgumentOutOfRangeException()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => TempPasswordGenerator.Generate(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_100_Samples_All_Pass_Diversity()
|
||||||
|
{
|
||||||
|
const string symbols = "!@#$%&*+-=?";
|
||||||
|
for (int i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate(12);
|
||||||
|
Assert.True(pwd.Length >= 12);
|
||||||
|
Assert.True(pwd.Any(char.IsUpper));
|
||||||
|
Assert.True(pwd.Any(char.IsLower));
|
||||||
|
Assert.True(pwd.Any(char.IsDigit));
|
||||||
|
Assert.True(pwd.Any(c => symbols.Contains(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Custom_Length_Respects_Length()
|
||||||
|
{
|
||||||
|
var pwd = TempPasswordGenerator.Generate(16);
|
||||||
|
Assert.Equal(16, pwd.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ using SIGCM2.Domain.Entities;
|
|||||||
|
|
||||||
namespace SIGCM2.Application.Tests.Domain;
|
namespace SIGCM2.Application.Tests.Domain;
|
||||||
|
|
||||||
|
// ── UDT-008 tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class UsuarioTests
|
public class UsuarioTests
|
||||||
{
|
{
|
||||||
// Happy path: constructor sets all properties correctly
|
// Happy path: constructor sets all properties correctly
|
||||||
@@ -69,4 +71,172 @@ public class UsuarioTests
|
|||||||
var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false);
|
var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false);
|
||||||
Assert.False(usuario.Activo);
|
Assert.False(usuario.Activo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: new properties ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_Defaults_MustChangePassword_False()
|
||||||
|
{
|
||||||
|
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
|
||||||
|
Assert.False(u.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_Defaults_FechaModificacion_Null()
|
||||||
|
{
|
||||||
|
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
|
||||||
|
Assert.Null(u.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_Defaults_UltimoLogin_Null()
|
||||||
|
{
|
||||||
|
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
|
||||||
|
Assert.Null(u.UltimoLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: WithUpdatedProfile ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedProfile_Returns_NewInstance()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true);
|
||||||
|
Assert.NotSame(u, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedProfile_Sets_Fields_Correctly()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false);
|
||||||
|
Assert.Equal("Pedro", updated.Nombre);
|
||||||
|
Assert.Equal("Gómez", updated.Apellido);
|
||||||
|
Assert.Equal("p@g.com", updated.Email);
|
||||||
|
Assert.Equal("cajero", updated.Rol);
|
||||||
|
Assert.False(updated.Activo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedProfile_Sets_FechaModificacion_To_UtcNow()
|
||||||
|
{
|
||||||
|
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUpdatedProfile("A", "B", null, "admin", true);
|
||||||
|
Assert.NotNull(updated.FechaModificacion);
|
||||||
|
Assert.True(updated.FechaModificacion >= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedProfile_Preserves_Immutable_Fields()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true);
|
||||||
|
Assert.Equal(u.Id, updated.Id);
|
||||||
|
Assert.Equal(u.Username, updated.Username);
|
||||||
|
Assert.Equal(u.PasswordHash, updated.PasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: WithNewPasswordHash ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNewPasswordHash_Returns_NewInstance()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false);
|
||||||
|
Assert.NotSame(u, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNewPasswordHash_Sets_Hash_And_MustChange()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true);
|
||||||
|
Assert.Equal("newhash", updated.PasswordHash);
|
||||||
|
Assert.True(updated.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNewPasswordHash_Clears_MustChange_When_False()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario(mustChangePassword: true);
|
||||||
|
var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false);
|
||||||
|
Assert.False(updated.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNewPasswordHash_Sets_FechaModificacion()
|
||||||
|
{
|
||||||
|
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithNewPasswordHash("hash2", false);
|
||||||
|
Assert.NotNull(updated.FechaModificacion);
|
||||||
|
Assert.True(updated.FechaModificacion >= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: WithUltimoLogin ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUltimoLogin_Returns_NewInstance()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUltimoLogin(DateTime.UtcNow);
|
||||||
|
Assert.NotSame(u, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUltimoLogin_Sets_UltimoLogin()
|
||||||
|
{
|
||||||
|
var ts = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithUltimoLogin(ts);
|
||||||
|
Assert.Equal(ts, updated.UltimoLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUltimoLogin_Does_NOT_Touch_FechaModificacion()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var originalFecha = u.FechaModificacion;
|
||||||
|
var updated = u.WithUltimoLogin(DateTime.UtcNow);
|
||||||
|
Assert.Equal(originalFecha, updated.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UDT-008: WithMustChangePassword ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithMustChangePassword_Sets_Value_True()
|
||||||
|
{
|
||||||
|
var u = MakeUsuario(mustChangePassword: false);
|
||||||
|
var updated = u.WithMustChangePassword(true);
|
||||||
|
Assert.True(updated.MustChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithMustChangePassword_Sets_FechaModificacion()
|
||||||
|
{
|
||||||
|
var before = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var u = MakeUsuario();
|
||||||
|
var updated = u.WithMustChangePassword(true);
|
||||||
|
Assert.NotNull(updated.FechaModificacion);
|
||||||
|
Assert.True(updated.FechaModificacion >= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Usuario MakeUsuario(bool mustChangePassword = false)
|
||||||
|
=> new(
|
||||||
|
id: 1,
|
||||||
|
username: "testuser",
|
||||||
|
passwordHash: "$2a$12$hash",
|
||||||
|
nombre: "Test",
|
||||||
|
apellido: "User",
|
||||||
|
email: "test@x.com",
|
||||||
|
rol: "admin",
|
||||||
|
permisosJson: "[]",
|
||||||
|
activo: true,
|
||||||
|
fechaModificacion: null,
|
||||||
|
ultimoLogin: null,
|
||||||
|
mustChangePassword: mustChangePassword
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Usuarios;
|
||||||
|
|
||||||
|
public class ChangeMyPasswordCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
|
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||||
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly ChangeMyPasswordCommandHandler _handler;
|
||||||
|
|
||||||
|
public ChangeMyPasswordCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Usuario MakeUser(int id = 1, bool mustChangePassword = false)
|
||||||
|
=> new(id, "user" + id, "$2a$12$oldhash", "Test", "User", null, "cajero", "[]", true,
|
||||||
|
mustChangePassword: mustChangePassword);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Happy_Path_Hashes_New_Password_Clears_MustChange()
|
||||||
|
{
|
||||||
|
var user = MakeUser(1, mustChangePassword: true);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(user);
|
||||||
|
_hasher.Verify("oldPass1!", "$2a$12$oldhash").Returns(true);
|
||||||
|
_hasher.Hash("newPass2!").Returns("$2a$12$newhash");
|
||||||
|
|
||||||
|
await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!"));
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdatePasswordAsync(1, "$2a$12$newhash", false, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Throws_InvalidOldPasswordException_When_Wrong_Old()
|
||||||
|
{
|
||||||
|
var user = MakeUser(1);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(user);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(false);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOldPasswordException>(
|
||||||
|
() => _handler.Handle(new ChangeMyPasswordCommand(1, "wrongPass!", "newPass2!")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(9999, Arg.Any<CancellationToken>()).Returns((Usuario?)null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UsuarioNotFoundException>(
|
||||||
|
() => _handler.Handle(new ChangeMyPasswordCommand(9999, "old", "new1234")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Does_NOT_Revoke_Own_Refresh_Tokens()
|
||||||
|
{
|
||||||
|
var user = MakeUser(1);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(user);
|
||||||
|
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
|
||||||
|
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$newhash");
|
||||||
|
|
||||||
|
await _handler.Handle(new ChangeMyPasswordCommand(1, "oldPass1!", "newPass2!"));
|
||||||
|
|
||||||
|
// spec REQ-BCP-05: change password does NOT revoke own tokens
|
||||||
|
await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Usuarios.Deactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Usuarios;
|
||||||
|
|
||||||
|
public class DeactivateUsuarioCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly DeactivateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
|
public DeactivateUsuarioCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo);
|
||||||
|
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Usuario MakeUser(int id = 5, string rol = "cajero", bool activo = true)
|
||||||
|
=> new(id, "user" + id, "$2a$12$hash", "Test", "User", null, rol, "[]", activo);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Deactivates_Active_User_Returns_Activo_False()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, "cajero", true);
|
||||||
|
var deactivated = MakeUser(5, "cajero", false);
|
||||||
|
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
_repo.GetDetailAsync(5, Arg.Any<CancellationToken>()).Returns(deactivated);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
Assert.False(result.Activo);
|
||||||
|
await _repo.Received(1).UpdateAsync(5, Arg.Any<UpdateUsuarioFields>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Idempotent_When_Already_Inactive_No_FechaModificacion_Change()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, "cajero", false); // already inactive
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
// Idempotent: should NOT call UpdateAsync
|
||||||
|
await _repo.DidNotReceive().UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateUsuarioFields>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
Assert.False(result.Activo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Throws_LastAdminLockoutException_When_Last_Admin()
|
||||||
|
{
|
||||||
|
var lastAdmin = MakeUser(1, "admin", true);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(lastAdmin);
|
||||||
|
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(1);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LastAdminLockoutException>(
|
||||||
|
() => _handler.Handle(new DeactivateUsuarioCommand(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Revokes_Refresh_Tokens_When_Deactivating_Active_User()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, "cajero", true);
|
||||||
|
var deactivated = MakeUser(5, "cajero", false);
|
||||||
|
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
_repo.GetDetailAsync(5, Arg.Any<CancellationToken>()).Returns(deactivated);
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(5, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Does_NOT_Revoke_Tokens_When_Already_Inactive_Idempotent()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, "cajero", false); // already inactive
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
await _refreshRepo.DidNotReceive().RevokeAllActiveForUserAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(9999, Arg.Any<CancellationToken>()).Returns((Usuario?)null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UsuarioNotFoundException>(
|
||||||
|
() => _handler.Handle(new DeactivateUsuarioCommand(9999)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Usuarios.Reactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Usuarios;
|
||||||
|
|
||||||
|
public class ReactivateUsuarioCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
|
private readonly ReactivateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
|
public ReactivateUsuarioCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ReactivateUsuarioCommandHandler(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Usuario MakeUser(int id = 5, bool activo = false)
|
||||||
|
=> new(id, "user" + id, "$2a$12$hash", "Test", "User", null, "cajero", "[]", activo);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Reactivates_Inactive_User_Returns_Activo_True()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, false);
|
||||||
|
var reactivated = MakeUser(5, true);
|
||||||
|
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
_repo.GetDetailAsync(5, Arg.Any<CancellationToken>()).Returns(reactivated);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ReactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
Assert.True(result.Activo);
|
||||||
|
await _repo.Received(1).UpdateAsync(5, Arg.Any<UpdateUsuarioFields>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Idempotent_When_Already_Active()
|
||||||
|
{
|
||||||
|
var target = MakeUser(5, true); // already active
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(target);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ReactivateUsuarioCommand(5));
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateUsuarioFields>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||||
|
Assert.True(result.Activo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(9999, Arg.Any<CancellationToken>()).Returns((Usuario?)null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UsuarioNotFoundException>(
|
||||||
|
() => _handler.Handle(new ReactivateUsuarioCommand(9999)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user