feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico
- RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409)
- ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409)
- DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure)
- CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository)
- Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests
- Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes
- tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test
This commit is contained in:
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Roles.Create;
|
||||
using SIGCM2.Application.Roles.Deactivate;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Application.Roles.Get;
|
||||
using SIGCM2.Application.Roles.List;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/roles")]
|
||||
[Authorize(Roles = "admin")]
|
||||
public sealed class RolesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateRolCommand> _createValidator;
|
||||
private readonly IValidator<UpdateRolCommand> _updateValidator;
|
||||
|
||||
public RolesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateRolCommand> createValidator,
|
||||
IValidator<UpdateRolCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a role by its code. Requires admin role.</summary>
|
||||
[HttpGet("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetByCodigo(string codigo)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new role. Requires admin role.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRolRequest request)
|
||||
{
|
||||
var command = new CreateRolCommand(
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Descripcion: request.Descripcion);
|
||||
|
||||
var validation = await _createValidator.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<CreateRolCommand, RolCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
|
||||
[HttpPut("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(string codigo, [FromBody] UpdateRolRequest request)
|
||||
{
|
||||
// Codigo comes from the route — body.codigo (if present) is ignored by design.
|
||||
var command = new UpdateRolCommand(
|
||||
Codigo: codigo,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Descripcion: request.Descripcion,
|
||||
Activo: request.Activo);
|
||||
|
||||
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<UpdateRolCommand, RolDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
|
||||
[HttpDelete("{codigo}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Deactivate(string codigo)
|
||||
{
|
||||
await _dispatcher.Send<DeactivateRolCommand, RolDto>(new DeactivateRolCommand(codigo));
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion);
|
||||
public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo);
|
||||
@@ -71,6 +71,42 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolNotFoundException rolNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_not_found",
|
||||
message = rolNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolAlreadyExistsException rolExistsEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_already_exists",
|
||||
message = rolExistsEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolInUseException rolInUseEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_in_use",
|
||||
message = rolInUseEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ValidationException validationEx:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
|
||||
@@ -4,6 +4,12 @@ using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Auth.Login;
|
||||
using SIGCM2.Application.Auth.Logout;
|
||||
using SIGCM2.Application.Auth.Refresh;
|
||||
using SIGCM2.Application.Roles.Create;
|
||||
using SIGCM2.Application.Roles.Deactivate;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Application.Roles.Get;
|
||||
using SIGCM2.Application.Roles.List;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
@@ -18,6 +24,13 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<CreateUsuarioCommand, UsuarioCreatedDto>, CreateUsuarioCommandHandler>();
|
||||
|
||||
// Roles (UDT-004)
|
||||
services.AddScoped<ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>, ListRolesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRolByCodigoQuery, RolDto>, GetRolByCodigoQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<CreateRolCommand, RolCreatedDto>, CreateRolCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Auth;
|
||||
|
||||
namespace SIGCM2.Application.Usuarios.Create;
|
||||
|
||||
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
|
||||
{
|
||||
// Whitelist aligned with canonical seeds in dbo.Rol (migration V003).
|
||||
// Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository.
|
||||
private static readonly string[] ValidRoles =
|
||||
[
|
||||
"admin", "cajero", "operador_ctacte", "picadora",
|
||||
"jefe_publicidad", "productor", "diagramacion", "reportes"
|
||||
];
|
||||
|
||||
private const int UsernameMinLength = 3;
|
||||
private const int UsernameMaxLength = 50;
|
||||
private const int NombreMaxLength = 100;
|
||||
private const int ApellidoMaxLength = 100;
|
||||
private const int EmailMaxLength = 150;
|
||||
|
||||
public CreateUsuarioCommandValidator() : this(new AuthOptions()) { }
|
||||
|
||||
public CreateUsuarioCommandValidator(AuthOptions authOptions)
|
||||
public CreateUsuarioCommandValidator(AuthOptions authOptions, IRolRepository rolRepository)
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty().WithMessage("El nombre de usuario es requerido.")
|
||||
@@ -52,10 +43,13 @@ public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsua
|
||||
.MaximumLength(EmailMaxLength).WithMessage($"El email no puede superar los {EmailMaxLength} caracteres.")
|
||||
.When(x => x.Email is not null);
|
||||
|
||||
// Rol: lookup dinámico contra dbo.Rol (UDT-004).
|
||||
// MustAsync requiere ValidateAsync en el call site — controllers ya usan ValidateAsync.
|
||||
RuleFor(x => x.Rol)
|
||||
.NotEmpty().WithMessage("El rol es requerido.")
|
||||
.Must(r => ValidRoles.Contains(r))
|
||||
.WithMessage($"El rol debe ser uno de: {string.Join(", ", ValidRoles)}.");
|
||||
.MustAsync(async (codigo, ct) =>
|
||||
!string.IsNullOrEmpty(codigo) && await rolRepository.ExistsActiveByCodigoAsync(codigo, ct))
|
||||
.WithMessage("El rol debe existir en el sistema y estar activo.");
|
||||
}
|
||||
|
||||
private static bool ContainsLetter(string value) =>
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton(new SqlConnectionFactory(connectionString));
|
||||
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IRolRepository, RolRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
Reference in New Issue
Block a user