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:
2026-04-15 12:50:24 -03:00
parent 34b714750a
commit 6f999b8fcd
11 changed files with 722 additions and 80 deletions

View File

@@ -1,4 +1,6 @@
using FluentValidation.TestHelper;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Usuarios.Create;
@@ -6,8 +8,19 @@ namespace SIGCM2.Application.Tests.Usuarios.Create;
public class CreateUsuarioCommandValidatorTests
{
private static CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
new(opts ?? new AuthOptions());
private readonly IRolRepository _roles = Substitute.For<IRolRepository>();
public CreateUsuarioCommandValidatorTests()
{
// Default mock behavior: canonical seeds are active; unknown codes are not.
var canonical = new[] { "admin", "cajero", "operador_ctacte", "picadora",
"jefe_publicidad", "productor", "diagramacion", "reportes" };
foreach (var code in canonical)
_roles.ExistsActiveByCodigoAsync(code, Arg.Any<CancellationToken>()).Returns(true);
}
private CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
new(opts ?? new AuthOptions(), _roles);
private static CreateUsuarioCommand ValidCommand() => new(
Username: "operador1",
@@ -20,136 +33,138 @@ public class CreateUsuarioCommandValidatorTests
// ── Happy paths ──────────────────────────────────────────────────────────
[Fact]
public void Validate_ValidCommand_NoErrors()
public async Task Validate_ValidCommand_NoErrors()
{
var result = BuildValidator().TestValidate(ValidCommand());
var result = await BuildValidator().TestValidateAsync(ValidCommand());
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_NullEmail_IsValid()
public async Task Validate_NullEmail_IsValid()
{
var cmd = ValidCommand() with { Email = null };
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
var result = await BuildValidator().TestValidateAsync(cmd);
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_ValidEmailPresent_NoErrors()
public async Task Validate_ValidEmailPresent_NoErrors()
{
var cmd = ValidCommand() with { Email = "juan@example.com" };
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
var result = await BuildValidator().TestValidateAsync(cmd);
result.ShouldNotHaveAnyValidationErrors();
}
// ── Username ─────────────────────────────────────────────────────────────
[Fact]
public void Validate_EmptyUsername_HasError()
public async Task Validate_EmptyUsername_HasError()
{
var cmd = ValidCommand() with { Username = "" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
}
[Fact]
public void Validate_UsernameTooShort_HasError()
public async Task Validate_UsernameTooShort_HasError()
{
var cmd = ValidCommand() with { Username = "ab" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
}
[Fact]
public void Validate_UsernameTooLong_HasError()
public async Task Validate_UsernameTooLong_HasError()
{
var cmd = ValidCommand() with { Username = new string('a', 51) };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
}
[Theory]
[InlineData("abc")] // 3 chars — boundary valid
[InlineData("user.name")] // dot allowed
[InlineData("user-name")] // dash allowed
[InlineData("user_name")] // underscore allowed
[InlineData("user123")] // alphanumeric
public void Validate_UsernameValidFormats_NoError(string username)
[InlineData("abc")]
[InlineData("user.name")]
[InlineData("user-name")]
[InlineData("user_name")]
[InlineData("user123")]
public async Task Validate_UsernameValidFormats_NoError(string username)
{
var cmd = ValidCommand() with { Username = username };
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Username);
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Username);
}
[Theory]
[InlineData("user name")] // space not allowed
[InlineData("user@name")] // @ not allowed
[InlineData("user#1")] // # not allowed
public void Validate_UsernameInvalidChars_HasError(string username)
[InlineData("user name")]
[InlineData("user@name")]
[InlineData("user#1")]
public async Task Validate_UsernameInvalidChars_HasError(string username)
{
var cmd = ValidCommand() with { Username = username };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
}
// ── Password ─────────────────────────────────────────────────────────────
[Fact]
public void Validate_EmptyPassword_HasError()
public async Task Validate_EmptyPassword_HasError()
{
var cmd = ValidCommand() with { Password = "" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
}
[Fact]
public void Validate_PasswordTooShort_HasError()
public async Task Validate_PasswordTooShort_HasError()
{
var cmd = ValidCommand() with { Password = "Ab1cd5" }; // 6 chars < 8
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
var cmd = ValidCommand() with { Password = "Ab1cd5" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
}
[Fact]
public void Validate_PasswordNoLetter_HasError()
public async Task Validate_PasswordNoLetter_HasError()
{
var cmd = ValidCommand() with { Password = "12345678" }; // digits only
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
var cmd = ValidCommand() with { Password = "12345678" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
}
[Fact]
public void Validate_PasswordNoDigit_HasError()
public async Task Validate_PasswordNoDigit_HasError()
{
var cmd = ValidCommand() with { Password = "abcdefgh" }; // letters only
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
var cmd = ValidCommand() with { Password = "abcdefgh" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
}
[Fact]
public void Validate_PasswordExactMinLength_NoError()
public async Task Validate_PasswordExactMinLength_NoError()
{
var cmd = ValidCommand() with { Password = "Secre123" }; // exactly 8, letter + digit
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Password);
var cmd = ValidCommand() with { Password = "Secre123" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Password);
}
// ── Nombre / Apellido ────────────────────────────────────────────────────
[Fact]
public void Validate_EmptyNombre_HasError()
public async Task Validate_EmptyNombre_HasError()
{
var cmd = ValidCommand() with { Nombre = "" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre);
}
[Fact]
public void Validate_EmptyApellido_HasError()
public async Task Validate_EmptyApellido_HasError()
{
var cmd = ValidCommand() with { Apellido = "" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido);
}
[Fact]
public void Validate_NombreTooLong_HasError()
public async Task Validate_NombreTooLong_HasError()
{
var cmd = ValidCommand() with { Nombre = new string('a', 101) };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre);
}
[Fact]
public void Validate_ApellidoTooLong_HasError()
public async Task Validate_ApellidoTooLong_HasError()
{
var cmd = ValidCommand() with { Apellido = new string('a', 101) };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido);
}
// ── Rol ──────────────────────────────────────────────────────────────────
@@ -163,36 +178,61 @@ public class CreateUsuarioCommandValidatorTests
[InlineData("productor")]
[InlineData("diagramacion")]
[InlineData("reportes")]
public void Validate_ValidRoles_NoError(string rol)
public async Task Validate_CanonicalActiveRoles_NoError(string rol)
{
var cmd = ValidCommand() with { Rol = rol };
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Rol);
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Rol);
}
[Theory]
[InlineData("superuser")]
[InlineData("ADMIN")] // case-sensitive
[InlineData("root")]
[InlineData("")]
public void Validate_InvalidRol_HasError(string rol)
[Fact]
public async Task Validate_RolInexistente_HasError()
{
var cmd = ValidCommand() with { Rol = rol };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Rol);
_roles.ExistsActiveByCodigoAsync("superuser", Arg.Any<CancellationToken>()).Returns(false);
var cmd = ValidCommand() with { Rol = "superuser" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
[Fact]
public async Task Validate_RolInactivo_HasError()
{
// The repository reports NOT active (soft-deleted rol) → validator rejects.
_roles.ExistsActiveByCodigoAsync("picadora", Arg.Any<CancellationToken>()).Returns(false);
var cmd = ValidCommand() with { Rol = "picadora" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
[Fact]
public async Task Validate_RolEmptyString_HasError()
{
var cmd = ValidCommand() with { Rol = "" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
[Fact]
public async Task Validate_RolCaseSensitive_HasError()
{
// 'ADMIN' uppercase is not a canonical code; mock returns false by default.
_roles.ExistsActiveByCodigoAsync("ADMIN", Arg.Any<CancellationToken>()).Returns(false);
var cmd = ValidCommand() with { Rol = "ADMIN" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
// ── Email ────────────────────────────────────────────────────────────────
[Fact]
public void Validate_InvalidEmail_HasError()
public async Task Validate_InvalidEmail_HasError()
{
var cmd = ValidCommand() with { Email = "not-an-email" };
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email);
}
[Fact]
public void Validate_EmailTooLong_HasError()
public async Task Validate_EmailTooLong_HasError()
{
var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; // >150
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email);
}
}