UDT-008: Gestión completa de usuarios #11

Merged
dmolinari merged 16 commits from feature/UDT-008 into main 2026-04-16 00:01:36 +00:00
103 changed files with 5555 additions and 66 deletions

View File

@@ -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

View File

@@ -3,26 +3,48 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Update;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// UDT-001/UDT-008: Usuario management endpoints.
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
/// </summary>
[ApiController]
[Route("api/v1/users")]
[RequirePermission("administracion:usuarios:gestionar")]
public sealed class UsuariosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateUsuarioCommand> _validator;
private readonly IValidator<CreateUsuarioCommand> _createValidator;
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;
_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]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -38,7 +60,7 @@ public sealed class UsuariosController : ControllerBase
Email: request.Email,
Rol: request.Rol ?? string.Empty);
var validation = await _validator.ValidateAsync(command);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
@@ -51,8 +73,162 @@ public sealed class UsuariosController : ControllerBase
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
}
/// <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>
public sealed record CreateUsuarioRequest(
string? Username,
@@ -61,3 +237,14 @@ public sealed record CreateUsuarioRequest(
string? Apellido,
string? Email,
string? Rol);
public sealed record UpdateUsuarioRequest(
string? Nombre,
string? Apellido,
string? Email,
string? Rol,
bool? Activo);
public sealed record ChangeMyPasswordRequest(
string? OldPassword,
string? NewPassword);

View File

@@ -19,6 +19,56 @@ public sealed class ExceptionFilter : IExceptionFilter
{
switch (context.Exception)
{
case UsuarioNotFoundException usuarioNotFoundEx:
context.Result = new ObjectResult(new
{
error = "usuario_not_found",
message = usuarioNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case LastAdminLockoutException:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Type = "about:blank",
Title = "last-admin-lockout",
Status = 400,
Detail = "No se puede desactivar o cambiar el rol del último administrador activo."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case CannotSelfResetException:
context.Result = new ObjectResult(new
{
error = "cannot-self-reset",
message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case InvalidOldPasswordException:
context.Result = new ObjectResult(new
{
error = "invalid-old-password",
message = "La contraseña actual es incorrecta."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case UsernameAlreadyExistsException usernameEx:
context.Result = new ObjectResult(new
{

View File

@@ -1,3 +1,4 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
@@ -8,4 +9,12 @@ public interface IUsuarioRepository
Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByUsernameAsync(string username, 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);
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
@@ -17,6 +18,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
private readonly IClientContext _clientContext;
private readonly AuthOptions _authOptions;
private readonly IRolPermisoRepository _rolPermisoRepository;
private readonly ILogger<LoginCommandHandler> _logger;
public LoginCommandHandler(
IUsuarioRepository repository,
@@ -26,7 +28,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
IRefreshTokenGenerator refreshGenerator,
IClientContext clientContext,
AuthOptions authOptions,
IRolPermisoRepository rolPermisoRepository)
IRolPermisoRepository rolPermisoRepository,
ILogger<LoginCommandHandler> logger)
{
_repository = repository;
_hasher = hasher;
@@ -36,6 +39,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_clientContext = clientContext;
_authOptions = authOptions;
_rolPermisoRepository = rolPermisoRepository;
_logger = logger;
}
public async Task<LoginResponseDto> Handle(LoginCommand command)
@@ -61,8 +65,18 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_clientContext.Ip, _clientContext.UserAgent);
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
// 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 permisos = permisoEntities.Select(p => p.Codigo).ToArray();
@@ -72,9 +86,11 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
Usuario: new UsuarioDto(
Id: usuario.Id,
Username: usuario.Username,
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
Rol: usuario.Rol,
Permisos: permisos
Permisos: permisos,
MustChangePassword: usuario.MustChangePassword
)
);
}

View File

@@ -9,7 +9,9 @@ public sealed record LoginResponseDto(
public sealed record UsuarioDto(
int Id,
string Username, // UDT-008
string Nombre,
string Rol,
string[] Permisos
string[] Permisos,
bool MustChangePassword // UDT-008
);

View 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
);

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

View 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;
}

View 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
);

View 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
);

View 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
);

View File

@@ -4,6 +4,7 @@ using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Application.Common;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol;
@@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Update;
namespace SIGCM2.Application;
@@ -40,6 +48,15 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
// Usuarios (UDT-008)
services.AddScoped<ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>, ListUsuariosQueryHandler>();
services.AddScoped<ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>, GetUsuarioByIdQueryHandler>();
services.AddScoped<ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>, UpdateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>, DeactivateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>, ReactivateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
public sealed record ChangeMyPasswordCommand(
int UsuarioId,
string OldPassword,
string NewPassword
);

View File

@@ -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;
}
}

View File

@@ -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.");
}
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Deactivate;
public sealed record DeactivateUsuarioCommand(int UsuarioId);

View File

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

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.GetById;
public sealed record GetUsuarioByIdQuery(int Id);

View File

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

View File

@@ -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
);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Usuarios.List;
public sealed record ListUsuariosQuery(
int Page,
int PageSize,
string? Rol,
bool? Activo,
string? Search
);

View File

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

View File

@@ -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
);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Reactivate;
public sealed record ReactivateUsuarioCommand(int UsuarioId);

View File

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

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId);

View File

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

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordResponse(
string TempPassword,
bool MustChangeOnLogin
);

View File

@@ -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
);

View File

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

View File

@@ -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.");
}
}

View File

@@ -12,6 +12,11 @@ public sealed class Usuario
public string PermisosJson { get; }
public bool Activo { get; }
// UDT-008: new properties
public DateTime? FechaModificacion { get; }
public DateTime? UltimoLogin { get; }
public bool MustChangePassword { get; }
public Usuario(
int id,
string username,
@@ -21,7 +26,10 @@ public sealed class Usuario
string? email,
string rol,
string permisosJson,
bool activo)
bool activo,
DateTime? fechaModificacion = null,
DateTime? ultimoLogin = null,
bool mustChangePassword = false)
{
Id = id;
Username = username;
@@ -32,11 +40,14 @@ public sealed class Usuario
Rol = rol;
PermisosJson = permisosJson;
Activo = activo;
FechaModificacion = fechaModificacion;
UltimoLogin = ultimoLogin;
MustChangePassword = mustChangePassword;
}
/// <summary>
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
/// Defaults: Activo=true, PermisosJson="[]".
/// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
/// </summary>
public static Usuario ForCreation(
string username,
@@ -55,6 +66,87 @@ public sealed class Usuario
email: email,
rol: rol,
permisosJson: "[]",
activo: true);
activo: true,
fechaModificacion: null,
ultimoLogin: null,
mustChangePassword: false);
}
// ── UDT-008: copy-with factory methods ────────────────────────────────────
/// <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);
}

View 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.") { }
}

View 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) { }
}

View File

@@ -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.") { }
}

View File

@@ -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.") { }
}

View 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;
}
}

View File

@@ -1,5 +1,7 @@
using System.Text;
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
@@ -19,7 +21,8 @@ public sealed class UsuarioRepository : IUsuarioRepository
SELECT
Id, Username, PasswordHash,
Nombre, Apellido, Email,
Rol, PermisosJson, Activo
Rol, PermisosJson, Activo,
FechaModificacion, UltimoLogin, MustChangePassword
FROM dbo.Usuario
WHERE Username = @Username
AND Activo = 1
@@ -41,7 +44,8 @@ public sealed class UsuarioRepository : IUsuarioRepository
SELECT
Id, Username, PasswordHash,
Nombre, Apellido, Email,
Rol, PermisosJson, Activo
Rol, PermisosJson, Activo,
FechaModificacion, UltimoLogin, MustChangePassword
FROM dbo.Usuario
WHERE Id = @Id
""";
@@ -94,6 +98,136 @@ public sealed class UsuarioRepository : IUsuarioRepository
return id;
}
// UDT-008 ─────────────────────────────────────────────────────────────────
public async Task UpdateUltimoLoginAsync(int id, DateTime utcNow, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.Usuario SET UltimoLogin = @Utc WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(sql, new { Utc = utcNow, Id = id });
}
public async Task<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)
=> new(
id: row.Id,
@@ -104,7 +238,10 @@ public sealed class UsuarioRepository : IUsuarioRepository
email: row.Email,
rol: row.Rol,
permisosJson: row.PermisosJson,
activo: row.Activo
activo: row.Activo,
fechaModificacion: row.FechaModificacion,
ultimoLogin: row.UltimoLogin,
mustChangePassword: row.MustChangePassword
);
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
@@ -117,6 +254,22 @@ public sealed class UsuarioRepository : IUsuarioRepository
string? Email,
string Rol,
string PermisosJson,
bool Activo
bool Activo,
DateTime? FechaModificacion,
DateTime? UltimoLogin,
bool MustChangePassword
);
private sealed record UsuarioPagedRow(
int Id,
string Username,
string Nombre,
string Apellido,
string? Email,
string Rol,
bool Activo,
DateTime? UltimoLogin,
DateTime? FechaModificacion,
int TotalCount
);
}

View File

@@ -1,4 +1,4 @@
import { Menu, LogOut, User } from 'lucide-react'
import { Menu, LogOut, User, Lock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import {
Sheet,
@@ -85,6 +85,10 @@ export function AppHeader() {
<User className="mr-2 h-4 w-4" />
Mi perfil
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void navigate('/perfil/contrasena')}>
<Lock className="mr-2 h-4 w-4" />
Cambiar contraseña
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@@ -6,6 +6,7 @@ import {
Zap,
Settings,
UserPlus,
Users,
ShieldCheck,
KeyRound,
} from 'lucide-react'
@@ -94,6 +95,18 @@ export function SidebarNav() {
Administración
</span>
</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
to="/usuarios/nuevo"
className={cn(

View 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}</>
}

View File

@@ -10,6 +10,7 @@ export interface LoginResponseDto {
nombre: string
rol: string
permisos: string[]
mustChangePassword: boolean // UDT-008
}
}

View File

@@ -20,6 +20,7 @@ export function useLogin() {
nombre: data.usuario.nombre,
rol: data.usuario.rol,
permisos: data.usuario.permisos ?? [],
mustChangePassword: data.usuario.mustChangePassword ?? false, // UDT-008
},
accessToken: data.accessToken,
refreshToken: data.refreshToken,

View 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)
}

View File

@@ -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),
})
}

View 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>
)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'] })
},
})
}

View 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'] })
},
})
}

View 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),
})
}

View 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'] })
},
})
}

View 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,
})
}

View 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,
})
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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
}

View File

@@ -1,8 +1,13 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import { ProtectedRoute } from './components/routing/ProtectedRoute'
import { MustChangePasswordGate } from './components/routing/MustChangePasswordGate'
import { LoginPage } from './features/auth/pages/LoginPage'
import { CreateUserPage } from './features/users/pages/CreateUserPage'
import { UsersListPage } from './features/users/pages/UsersListPage'
import { UserDetailPage } from './features/users/pages/UserDetailPage'
import { UserEditPage } from './features/users/pages/UserEditPage'
import { ChangeMyPasswordPage } from './features/profile/pages/ChangeMyPasswordPage'
import { RolesPage } from './features/roles/pages/RolesPage'
import { NewRolPage } from './features/roles/pages/NewRolPage'
import { EditRolPage } from './features/roles/pages/EditRolPage'
@@ -19,9 +24,30 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
/**
* Wraps a protected route with ProtectedLayout + MustChangePasswordGate.
* The gate forces users with mustChangePassword=true to /perfil/contrasena.
*/
function ProtectedPage({
children,
requiredPermissions,
}: {
children: React.ReactNode
requiredPermissions?: string[]
}) {
return (
<ProtectedRoute requiredPermissions={requiredPermissions}>
<MustChangePasswordGate>
<ProtectedLayout>{children}</ProtectedLayout>
</MustChangePasswordGate>
</ProtectedRoute>
)
}
export function AppRoutes() {
return (
<Routes>
{/* Public routes */}
<Route
path="/login"
element={
@@ -32,71 +58,102 @@ export function AppRoutes() {
</PublicRoute>
}
/>
{/* Change password — protected but NO MustChangePasswordGate (avoids redirect loop) */}
<Route
path="/"
path="/perfil/contrasena"
element={
<ProtectedRoute>
<ProtectedLayout>
<HomePage />
<ChangeMyPasswordPage />
</ProtectedLayout>
</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
path="/usuarios/nuevo"
element={
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
<ProtectedLayout>
<CreateUserPage />
</ProtectedLayout>
</ProtectedRoute>
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
<CreateUserPage />
</ProtectedPage>
}
/>
<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
path="/admin/roles"
element={
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout>
<RolesPage />
</ProtectedLayout>
</ProtectedRoute>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<RolesPage />
</ProtectedPage>
}
/>
<Route
path="/admin/roles/nuevo"
element={
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout>
<NewRolPage />
</ProtectedLayout>
</ProtectedRoute>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<NewRolPage />
</ProtectedPage>
}
/>
<Route
path="/admin/roles/:codigo/editar"
element={
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout>
<EditRolPage />
</ProtectedLayout>
</ProtectedRoute>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<EditRolPage />
</ProtectedPage>
}
/>
<Route
path="/admin/permisos"
element={
<ProtectedRoute
<ProtectedPage
requiredPermissions={[
'administracion:roles_permisos:gestionar',
'administracion:permisos:ver',
]}
>
<ProtectedLayout>
<RolPermisosPage />
</ProtectedLayout>
</ProtectedRoute>
<RolPermisosPage />
</ProtectedPage>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -7,6 +7,7 @@ export interface AuthUser {
nombre: string
rol: string
permisos: string[]
mustChangePassword: boolean // UDT-008
}
interface SetAuthPayload {
@@ -22,6 +23,7 @@ interface AuthState {
refreshToken: string | null
expiresAt: number | null // ms epoch UTC
setAuth: (payload: SetAuthPayload) => void
updateUser: (patch: Partial<AuthUser>) => void // UDT-008
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
clearAuth: () => void
logout: () => Promise<void>
@@ -43,6 +45,11 @@ export const useAuthStore = create<AuthState>()(
expiresAt: Date.now() + payload.expiresIn * 1000,
}),
updateUser: (patch) =>
set((s) => ({
user: s.user ? { ...s.user, ...patch } : null,
})),
updateAccess: (accessToken, refreshToken, expiresAt) =>
set({ accessToken, refreshToken, expiresAt }),

View File

@@ -49,7 +49,7 @@ afterEach(() => {
function setAuth(accessToken: string, refreshToken: string) {
useAuthStore.setState({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [], mustChangePassword: false },
accessToken,
refreshToken,
expiresAt: Date.now() + 3600 * 1000,

View File

@@ -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()
})
})

View File

@@ -16,6 +16,7 @@ describe('CanPerform', () => {
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
mustChangePassword: false,
},
})
@@ -36,6 +37,7 @@ describe('CanPerform', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})
@@ -68,6 +70,7 @@ describe('CanPerform', () => {
nombre: 'Reportes',
rol: 'reportes',
permisos: [],
mustChangePassword: false,
},
})
@@ -89,6 +92,7 @@ describe('CanPerform', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})

View File

@@ -21,7 +21,7 @@ const mockLoginResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
refreshToken: 'refresh-token-abc',
expiresIn: 3600,
usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] },
usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false },
}
const server = setupServer(

View File

@@ -71,7 +71,7 @@ describe('ProtectedRoute', () => {
it('F-03-02: user autenticado sin restricciones → renderiza children', () => {
useAuthStore.setState({
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false },
})
render(
@@ -101,6 +101,7 @@ describe('ProtectedRoute', () => {
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
mustChangePassword: false,
},
})
@@ -126,7 +127,7 @@ describe('ProtectedRoute', () => {
it('F-03-04: requiredRoles no coincide → redirect a /', () => {
useAuthStore.setState({
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false },
})
render(
@@ -158,6 +159,7 @@ describe('ProtectedRoute', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})
@@ -191,6 +193,7 @@ describe('ProtectedRoute', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})
@@ -223,6 +226,7 @@ describe('ProtectedRoute', () => {
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
mustChangePassword: false,
},
})
@@ -254,6 +258,7 @@ describe('ProtectedRoute', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})

View File

@@ -19,6 +19,7 @@ const mockLoginResponseWithPermisos = {
nombre: 'Admin Sistema',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
mustChangePassword: false,
},
}
@@ -32,6 +33,21 @@ const mockLoginResponseEmptyPermisos = {
nombre: 'Cajero Test',
rol: 'cajero',
permisos: [],
mustChangePassword: false,
},
}
const mockLoginResponseMustChange = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
refreshToken: 'refresh-token-abc',
expiresIn: 3600,
usuario: {
id: 3,
username: 'newuser',
nombre: 'New User',
rol: 'cajero',
permisos: [],
mustChangePassword: true,
},
}
@@ -94,3 +110,44 @@ describe('useLogin — permisos propagation', () => {
expect(state.user?.permisos).not.toBeNull()
})
})
describe('useLogin — mustChangePassword propagation', () => {
it('F-login-03: persists mustChangePassword=false from login response', async () => {
server.use(
http.post(`${API_URL}/api/v1/auth/login`, () =>
HttpResponse.json(mockLoginResponseWithPermisos, { status: 200 }),
),
)
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
act(() => {
result.current.mutate({ username: 'admin', password: 'password' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const state = useAuthStore.getState()
expect(state.user?.mustChangePassword).toBe(false)
})
it('F-login-04: persists mustChangePassword=true from login response', async () => {
server.use(
http.post(`${API_URL}/api/v1/auth/login`, () =>
HttpResponse.json(mockLoginResponseMustChange, { status: 200 }),
),
)
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
act(() => {
result.current.mutate({ username: 'newuser', password: 'password' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const state = useAuthStore.getState()
expect(state.user?.mustChangePassword).toBe(true)
expect(state.user?.username).toBe('newuser')
})
})

View File

@@ -16,6 +16,7 @@ describe('usePermission', () => {
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
mustChangePassword: false,
},
})
@@ -31,6 +32,7 @@ describe('usePermission', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})
@@ -46,6 +48,7 @@ describe('usePermission', () => {
nombre: 'Reportes',
rol: 'reportes',
permisos: [],
mustChangePassword: false,
},
})
@@ -68,6 +71,7 @@ describe('usePermission', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})
@@ -85,6 +89,7 @@ describe('usePermission', () => {
nombre: 'Cajero',
rol: 'cajero',
permisos: ['ventas:contado:crear'],
mustChangePassword: false,
},
})

View 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()
})
})

View 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')
})
})

View 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()
})
})

View 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()
})
})

View 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')
})
})

View 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)
})
})

View 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=')
})
})

View 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' })
})
})

View 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')
})
})

View File

@@ -1,6 +1,25 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { useAuthStore } from '../../stores/authStore'
// Canonical test user fixtures
const adminUser = {
id: 1,
username: 'admin',
nombre: 'Admin',
rol: 'admin',
permisos: [] as string[],
mustChangePassword: false,
}
const cajeroUser = {
id: 2,
username: 'cajero',
nombre: 'Cajero',
rol: 'cajero',
permisos: [] as string[],
mustChangePassword: false,
}
describe('authStore', () => {
beforeEach(() => {
// Reset store state before each test
@@ -28,7 +47,7 @@ describe('authStore', () => {
describe('setAuth', () => {
it('stores user and accessToken in state', () => {
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token',
expiresIn: 3600,
@@ -43,7 +62,7 @@ describe('authStore', () => {
it('persists auth data to localStorage under auth-storage key', () => {
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token',
expiresIn: 3600,
@@ -61,7 +80,7 @@ describe('authStore', () => {
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
const before = Date.now()
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'access-token-abc',
refreshToken: 'opaque-refresh-xyz',
expiresIn: 3600,
@@ -92,6 +111,7 @@ describe('authStore', () => {
nombre: 'Admin',
rol: 'admin',
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
mustChangePassword: false,
},
accessToken: 'access-token',
refreshToken: 'refresh-token',
@@ -108,7 +128,7 @@ describe('authStore', () => {
it('F-04-02: setAuth con permisos vacíos → user.permisos es [] (no null)', () => {
const payload = {
user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] },
user: cajeroUser,
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
@@ -120,12 +140,83 @@ describe('authStore', () => {
expect(state.user?.permisos).toEqual([])
expect(state.user?.permisos).not.toBeNull()
})
it('persists mustChangePassword=true in state and localStorage', () => {
const payload = {
user: { ...adminUser, mustChangePassword: true },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const state = useAuthStore.getState()
expect(state.user?.mustChangePassword).toBe(true)
const stored = localStorage.getItem('auth-storage')
const parsed = JSON.parse(stored!)
expect(parsed.state.user.mustChangePassword).toBe(true)
})
it('persists mustChangePassword=false in state', () => {
const payload = {
user: { ...adminUser, mustChangePassword: false },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const state = useAuthStore.getState()
expect(state.user?.mustChangePassword).toBe(false)
})
})
describe('updateUser', () => {
it('updateUser_patches_mustChangePassword_preserves_rest', () => {
useAuthStore.getState().setAuth({
user: { ...adminUser, mustChangePassword: true },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
useAuthStore.getState().updateUser({ mustChangePassword: false })
const state = useAuthStore.getState()
expect(state.user?.mustChangePassword).toBe(false)
// Other fields preserved
expect(state.user?.username).toBe('admin')
expect(state.user?.rol).toBe('admin')
expect(state.user?.id).toBe(1)
})
it('updateUser_noops_when_user_null', () => {
// user is null — should not throw
expect(() => useAuthStore.getState().updateUser({ mustChangePassword: false })).not.toThrow()
expect(useAuthStore.getState().user).toBeNull()
})
it('updateUser_can_patch_username', () => {
useAuthStore.getState().setAuth({
user: adminUser,
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
useAuthStore.getState().updateUser({ username: 'new-admin' })
expect(useAuthStore.getState().user?.username).toBe('new-admin')
})
})
describe('clearAuth', () => {
it('F-04-03: clearAuth → user = null (permisos se limpian con el user)', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] },
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
@@ -139,7 +230,7 @@ describe('authStore', () => {
it('clearAuth_removesAllFields', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
@@ -157,9 +248,8 @@ describe('authStore', () => {
describe('updateAccess', () => {
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }
useAuthStore.getState().setAuth({
user: originalUser,
user: adminUser,
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresIn: 3600,
@@ -173,7 +263,7 @@ describe('authStore', () => {
expect(state.refreshToken).toBe('new-refresh')
expect(state.expiresAt).toBe(newExpiresAt)
// user should be preserved
expect(state.user).toEqual(originalUser)
expect(state.user).toEqual(adminUser)
})
})
@@ -181,7 +271,7 @@ describe('authStore', () => {
it('logout_callsApi_thenClearsAuth', async () => {
// Set up auth state with a token so logout() will try to call the API
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
@@ -201,14 +291,13 @@ describe('authStore', () => {
it('logout_apiFails_stillClearsAuth', async () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
// Should NOT throw even if the dynamic import fails
// (We test this by verifying clearAuth is always called)
let threw = false
try {
await useAuthStore.getState().logout()
@@ -226,7 +315,7 @@ describe('authStore', () => {
describe('legacy logout compatibility (via clearAuth)', () => {
it('clearAuth clears user and accessToken from state', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'some-token',
refreshToken: 'some-refresh',
expiresIn: 3600,
@@ -241,7 +330,7 @@ describe('authStore', () => {
it('clearAuth removes auth-storage from localStorage', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] },
user: adminUser,
accessToken: 'some-token',
refreshToken: 'some-refresh',
expiresIn: 3600,

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

View File

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

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

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

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

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

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using NSubstitute;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
@@ -19,6 +20,7 @@ public class LoginCommandHandlerTests
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
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 LoginCommandHandler _handler;
@@ -29,6 +31,10 @@ public class LoginCommandHandlerTests
_refreshGenerator.Generate().Returns("raw_refresh_token_value");
_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
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<Permiso>().AsReadOnly());
@@ -36,7 +42,7 @@ public class LoginCommandHandlerTests
_handler = new LoginCommandHandler(
_repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
_rolPermisoRepo);
_rolPermisoRepo, _logger);
}
// Scenario: valid credentials → returns token response with usuario populated
@@ -243,4 +249,78 @@ public class LoginCommandHandlerTests
t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
}
// ── UDT-008: username + mustChangePassword + UltimoLogin ─────────────────
[Fact]
public async Task Handle_PopulatesUsername_InUsuarioDto()
{
var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
_repository.GetByUsernameAsync("jperez").Returns(usuario);
_hasher.Verify(Arg.Any<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);
}
}

View File

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

View File

@@ -2,6 +2,8 @@ using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
// ── UDT-008 tests ────────────────────────────────────────────────────────────
public class UsuarioTests
{
// Happy path: constructor sets all properties correctly
@@ -69,4 +71,172 @@ public class UsuarioTests
var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false);
Assert.False(usuario.Activo);
}
// ── UDT-008: new properties ───────────────────────────────────────────────
[Fact]
public void ForCreation_Defaults_MustChangePassword_False()
{
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
Assert.False(u.MustChangePassword);
}
[Fact]
public void ForCreation_Defaults_FechaModificacion_Null()
{
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
Assert.Null(u.FechaModificacion);
}
[Fact]
public void ForCreation_Defaults_UltimoLogin_Null()
{
var u = Usuario.ForCreation("user", "hash", "A", "B", null, "cajero");
Assert.Null(u.UltimoLogin);
}
// ── UDT-008: WithUpdatedProfile ──────────────────────────────────────────
[Fact]
public void WithUpdatedProfile_Returns_NewInstance()
{
var u = MakeUsuario();
var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true);
Assert.NotSame(u, updated);
}
[Fact]
public void WithUpdatedProfile_Sets_Fields_Correctly()
{
var u = MakeUsuario();
var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false);
Assert.Equal("Pedro", updated.Nombre);
Assert.Equal("Gómez", updated.Apellido);
Assert.Equal("p@g.com", updated.Email);
Assert.Equal("cajero", updated.Rol);
Assert.False(updated.Activo);
}
[Fact]
public void WithUpdatedProfile_Sets_FechaModificacion_To_UtcNow()
{
var before = DateTime.UtcNow.AddSeconds(-1);
var u = MakeUsuario();
var updated = u.WithUpdatedProfile("A", "B", null, "admin", true);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
[Fact]
public void WithUpdatedProfile_Preserves_Immutable_Fields()
{
var u = MakeUsuario();
var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true);
Assert.Equal(u.Id, updated.Id);
Assert.Equal(u.Username, updated.Username);
Assert.Equal(u.PasswordHash, updated.PasswordHash);
}
// ── UDT-008: WithNewPasswordHash ─────────────────────────────────────────
[Fact]
public void WithNewPasswordHash_Returns_NewInstance()
{
var u = MakeUsuario();
var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false);
Assert.NotSame(u, updated);
}
[Fact]
public void WithNewPasswordHash_Sets_Hash_And_MustChange()
{
var u = MakeUsuario();
var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true);
Assert.Equal("newhash", updated.PasswordHash);
Assert.True(updated.MustChangePassword);
}
[Fact]
public void WithNewPasswordHash_Clears_MustChange_When_False()
{
var u = MakeUsuario(mustChangePassword: true);
var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false);
Assert.False(updated.MustChangePassword);
}
[Fact]
public void WithNewPasswordHash_Sets_FechaModificacion()
{
var before = DateTime.UtcNow.AddSeconds(-1);
var u = MakeUsuario();
var updated = u.WithNewPasswordHash("hash2", false);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
// ── UDT-008: WithUltimoLogin ──────────────────────────────────────────────
[Fact]
public void WithUltimoLogin_Returns_NewInstance()
{
var u = MakeUsuario();
var updated = u.WithUltimoLogin(DateTime.UtcNow);
Assert.NotSame(u, updated);
}
[Fact]
public void WithUltimoLogin_Sets_UltimoLogin()
{
var ts = new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc);
var u = MakeUsuario();
var updated = u.WithUltimoLogin(ts);
Assert.Equal(ts, updated.UltimoLogin);
}
[Fact]
public void WithUltimoLogin_Does_NOT_Touch_FechaModificacion()
{
var u = MakeUsuario();
var originalFecha = u.FechaModificacion;
var updated = u.WithUltimoLogin(DateTime.UtcNow);
Assert.Equal(originalFecha, updated.FechaModificacion);
}
// ── UDT-008: WithMustChangePassword ──────────────────────────────────────
[Fact]
public void WithMustChangePassword_Sets_Value_True()
{
var u = MakeUsuario(mustChangePassword: false);
var updated = u.WithMustChangePassword(true);
Assert.True(updated.MustChangePassword);
}
[Fact]
public void WithMustChangePassword_Sets_FechaModificacion()
{
var before = DateTime.UtcNow.AddSeconds(-1);
var u = MakeUsuario();
var updated = u.WithMustChangePassword(true);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
// ── helpers ───────────────────────────────────────────────────────────────
private static Usuario MakeUsuario(bool mustChangePassword = false)
=> new(
id: 1,
username: "testuser",
passwordHash: "$2a$12$hash",
nombre: "Test",
apellido: "User",
email: "test@x.com",
rol: "admin",
permisosJson: "[]",
activo: true,
fechaModificacion: null,
ultimoLogin: null,
mustChangePassword: mustChangePassword
);
}

View File

@@ -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>());
}
}

View File

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

View File

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

View File

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

View File

@@ -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