Compare commits

...

17 Commits

Author SHA1 Message Date
68897f446b Merge pull request 'UDT-008: Gestión completa de usuarios' (#11) from feature/UDT-008 into main 2026-04-16 00:01:36 +00:00
06908263f6 fix(web): cablear ResetPasswordModal en UserEditPage [UDT-008]
El row click de UsersListPage navega directo a /usuarios/:id/editar,
por lo que el modal montado solo en UserDetailPage no era alcanzable
desde el flujo real. Ahora tambien esta en el header del EditPage,
al lado del boton Volver, oculto cuando el target es el user logueado.
2026-04-15 21:00:08 -03:00
9e93c70d8b refactor(web): mover Cambiar contraseña de sidebar a menu perfil [UDT-008]
La seccion Mi cuenta en el sidebar quedaba desprolija con un unico item.
Se movio Cambiar contraseña al dropdown del avatar en AppHeader donde
pertenece semanticamente.
2026-04-15 20:55:26 -03:00
851fed8692 fix(web): cablear ResetPasswordModal en UserDetailPage [UDT-008]
El componente ResetPasswordModal estaba implementado pero nunca montado en una pagina.
Ahora se renderiza en UserDetailPage, oculto cuando el target es el usuario logueado
(evita hit de cannot-self-reset en backend).
2026-04-15 20:54:25 -03:00
2e2d4543ad feat(web): router wiring completo + nav link usuarios + MustChangePasswordGate integration [UDT-008]
- Agrega ProtectedPage helper que combina ProtectedRoute + MustChangePasswordGate + ProtectedLayout
- Rutas nuevas: /usuarios, /usuarios/:id, /usuarios/:id/editar con permisos RBAC
- /perfil/contrasena sin MustChangePasswordGate (evita redirect loop)
- Sidebar: sección "Mi cuenta" con cambio de contraseña; link Usuarios en sección admin
2026-04-15 18:12:54 -03:00
25ed0f6452 feat(web): ChangeMyPasswordPage + ResetPasswordModal — hooks, pages, modal [UDT-008] 2026-04-15 18:09:59 -03:00
64e0a8b5fb feat(web): UserDetailPage + UserEditPage — get/update/deactivate/reactivate hooks y pages [UDT-008] 2026-04-15 18:06:54 -03:00
9512f4125d feat(web): UsersListPage — api client, hook, filters, table, pagination [UDT-008] 2026-04-15 18:05:07 -03:00
d998d215e0 feat(web): authStore username+mustChangePassword + MustChangePasswordGate [UDT-008] 2026-04-15 18:02:20 -03:00
7d96d5ff18 feat(api): ResetPassword admin — TempPasswordGenerator, handler, endpoint POST /{id}/password/reset [UDT-008]
Batch 7: POST /api/v1/users/{id}/password/reset (admin only).
- TempPasswordGenerator: RandomNumberGenerator.Fill, 12-char min, full charset diversity, never logs result
- ResetUsuarioPasswordCommandHandler: self-reset guard, 404, hash, mustChangePassword=true, revoke all tokens
- ExceptionFilter: CannotSelfResetException → 400 {error: cannot-self-reset}
- Unit tests: TempPasswordGeneratorTests (8), ResetUsuarioPasswordCommandHandlerTests (5)
- Integration tests: ResetPasswordEndpointTests (6) — 200/length/self-reset/404/401/403
2026-04-15 17:55:45 -03:00
a3bd066f7b feat(api): ChangeMyPassword — validator, handler, endpoint PUT /me/password [UDT-008] 2026-04-15 17:52:15 -03:00
473566f255 feat(api): Deactivate + Reactivate usuarios — idempotentes, anti-lockout, revoke tokens [UDT-008] 2026-04-15 17:50:54 -03:00
14c385fdb1 feat(api): UpdateUsuario — handler, validator, anti-lockout guard, revoke tokens [UDT-008] 2026-04-15 17:49:19 -03:00
2925336783 feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008] 2026-04-15 17:46:23 -03:00
9dcd63543e feat(auth): extend LoginResponse with username + mustChangePassword + ultimoLogin [UDT-008] 2026-04-15 17:39:48 -03:00
d1f7b3805b feat(domain): V008 migration + Usuario with-methods + DomainException hierarchy [UDT-008] 2026-04-15 17:36:46 -03:00
5ddc5ddf02 chore(udt-008): bootstrap rama feature/UDT-008 [UDT-008] 2026-04-15 17:34:12 -03: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>
<ProtectedPage requiredPermissions={['administracion:usuarios:gestionar']}>
<CreateUserPage />
</ProtectedLayout>
</ProtectedRoute>
</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>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<RolesPage />
</ProtectedLayout>
</ProtectedRoute>
</ProtectedPage>
}
/>
<Route
path="/admin/roles/nuevo"
element={
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<NewRolPage />
</ProtectedLayout>
</ProtectedRoute>
</ProtectedPage>
}
/>
<Route
path="/admin/roles/:codigo/editar"
element={
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout>
<ProtectedPage requiredPermissions={['administracion:roles:gestionar']}>
<EditRolPage />
</ProtectedLayout>
</ProtectedRoute>
</ProtectedPage>
}
/>
<Route
path="/admin/permisos"
element={
<ProtectedRoute
<ProtectedPage
requiredPermissions={[
'administracion:roles_permisos:gestionar',
'administracion:permisos:ver',
]}
>
<ProtectedLayout>
<RolPermisosPage />
</ProtectedLayout>
</ProtectedRoute>
</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