From cdb8dcd03cf18eddfc500ae0d3832b0c02d143b0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:24:21 -0300 Subject: [PATCH 1/9] feat(api): login response permisos desde RolPermiso [UDT-006] --- .../Auth/Login/LoginCommandHandler.cs | 12 ++- .../Auth/Login/LoginCommandHandlerTests.cs | 85 +++++++++++++++++-- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index a7f12c8..b4d858c 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; @@ -17,6 +16,7 @@ public sealed class LoginCommandHandler : ICommandHandler Handle(LoginCommand command) @@ -59,8 +61,10 @@ public sealed class LoginCommandHandler : ICommandHandler(usuario.PermisosJson) - ?? Array.Empty(); + // UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson + // Usuario.PermisosJson queda reservado para UDT-008 (overrides por usuario) + var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol); + var permisos = permisoEntities.Select(p => p.Codigo).ToArray(); return new LoginResponseDto( AccessToken: accessToken, diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index c942368..7ceffe5 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -18,6 +18,7 @@ public class LoginCommandHandlerTests private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); + private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly LoginCommandHandler _handler; @@ -28,16 +29,21 @@ public class LoginCommandHandlerTests _refreshGenerator.Generate().Returns("raw_refresh_token_value"); _refreshRepo.AddAsync(Arg.Any()).Returns(1); + // Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben + _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any(), Arg.Any()) + .Returns(new List().AsReadOnly()); + _handler = new LoginCommandHandler( _repository, _hasher, _jwtService, - _refreshRepo, _refreshGenerator, _clientCtx, _authOptions); + _refreshRepo, _refreshGenerator, _clientCtx, _authOptions, + _rolPermisoRepo); } // Scenario: valid credentials → returns token response with usuario populated [Fact] public async Task Handle_ValidCredentials_ReturnsTokenResponse() { - var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); + var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); @@ -55,30 +61,93 @@ public class LoginCommandHandlerTests Assert.Equal("Admin Sys", result.Usuario.Nombre); Assert.Equal("admin", result.Usuario.Rol); Assert.NotNull(result.Usuario.Permisos); - Assert.Contains("*", result.Usuario.Permisos); + } + + // UDT-006 B-05: Permisos vienen desde IRolPermisoRepository, no desde PermisosJson + [Fact] + public async Task Handle_AdminLogin_PermisosFromRolPermisoRepository() + { + // Arrange + var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true); + _repository.GetByUsernameAsync("admin").Returns(usuario); + _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); + _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); + + var adminPermisos = new List + { + MakePermiso(1, "administracion:usuarios:gestionar"), + MakePermiso(2, "administracion:roles:gestionar"), + MakePermiso(3, "administracion:permisos:ver"), + }; + _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any()) + .Returns(adminPermisos.AsReadOnly() as IReadOnlyList); + + // Act + var result = await _handler.Handle(new LoginCommand("admin", "@Diego550@")); + + // Assert — permisos vienen del repo, no de PermisosJson + Assert.Equal(3, result.Usuario.Permisos.Length); + Assert.Contains("administracion:usuarios:gestionar", result.Usuario.Permisos); + Assert.Contains("administracion:roles:gestionar", result.Usuario.Permisos); + Assert.Contains("administracion:permisos:ver", result.Usuario.Permisos); + // IRolPermisoRepository fue consultado con el rol del usuario + await _rolPermisoRepo.Received(1).GetByRolCodigoAsync("admin", Arg.Any()); + } + + // UDT-006 B-05-03: Rol sin permisos en RolPermiso → array vacío (nunca null) + [Fact] + public async Task Handle_RolSinPermisos_PermisosArrayVacio() + { + // Arrange + var usuario = new Usuario(5, "rep1", "$2a$12$hash", "Rep", "User", null, "reportes", "[]", true); + _repository.GetByUsernameAsync("rep1").Returns(usuario); + _hasher.Verify("pass", "$2a$12$hash").Returns(true); + _jwtService.GenerateAccessToken(usuario).Returns("jwt.rep"); + + // repo devuelve lista vacía para "reportes" + _rolPermisoRepo.GetByRolCodigoAsync("reportes", Arg.Any()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + // Act + var result = await _handler.Handle(new LoginCommand("rep1", "pass")); + + // Assert — empty array, NOT null + Assert.NotNull(result.Usuario.Permisos); + Assert.Empty(result.Usuario.Permisos); } // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user [Fact] public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() { - var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero", - "[\"ventas:contado:create\",\"ventas:contado:read\"]", true); + var cajeroPermisos = new List + { + MakePermiso(10, "ventas:contado:crear"), + MakePermiso(11, "ventas:contado:cobrar"), + }; + var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "cajero", + "[]", true); _repository.GetByUsernameAsync("cajero1").Returns(usuario); _hasher.Verify("pass123", "$2a$12$hash3").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); var command = new LoginCommand("cajero1", "pass123"); var result = await _handler.Handle(command); Assert.Equal(42, result.Usuario.Id); Assert.Equal("María González", result.Usuario.Nombre); - Assert.Equal("Cajero", result.Usuario.Rol); + Assert.Equal("cajero", result.Usuario.Rol); Assert.Equal(2, result.Usuario.Permisos.Length); - Assert.Contains("ventas:contado:create", result.Usuario.Permisos); - Assert.Contains("ventas:contado:read", result.Usuario.Permisos); + Assert.Contains("ventas:contado:crear", result.Usuario.Permisos); + Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos); } + // Helper: construir Permiso via ForRead para tests + private static Permiso MakePermiso(int id, string codigo) => + Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow); + // Scenario: user does not exist → throws InvalidCredentialsException [Fact] public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException() From 58d0df601fd68865db48ac050d29e779e948b185 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:26:30 -0300 Subject: [PATCH 2/9] feat(api): RequirePermissionAttribute + PermissionAuthorizationHandler [UDT-006] --- .../PermissionAuthorizationHandler.cs | 71 +++++ .../RequirePermissionAttribute.cs | 35 +++ src/api/SIGCM2.Api/Program.cs | 6 + .../PermissionAuthorizationHandlerTests.cs | 246 ++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs create mode 100644 src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs create mode 100644 tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs diff --git a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..0efb172 --- /dev/null +++ b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Api.Authorization; + +/// +/// Authorization handler for . +/// Reads the "rol" claim from the authenticated user, queries +/// for the role's assigned permissions, and succeeds if at least one matches (OR semantics). +/// No caching — UDT-006 design decision D1: always authoritative from DB. +/// +public sealed class PermissionAuthorizationHandler + : AuthorizationHandler +{ + private readonly IRolPermisoRepository _rolPermisoRepo; + private readonly ILogger _logger; + + public PermissionAuthorizationHandler( + IRolPermisoRepository rolPermisoRepo, + ILogger logger) + { + _rolPermisoRepo = rolPermisoRepo; + _logger = logger; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + RequirePermissionAttribute requirement) + { + // 1. Must be authenticated — defense-in-depth (AuthorizeAttribute already requires it) + if (context.User?.Identity?.IsAuthenticated != true) + { + return; // implicit Fail — nothing Succeeded + } + + // 2. Extract "rol" claim — JwtBearer is configured with RoleClaimType="rol" + var rolCodigo = context.User.FindFirst("rol")?.Value; + if (string.IsNullOrWhiteSpace(rolCodigo)) + { + _logger.LogWarning( + "Authorization failed — token missing 'rol' claim for user {User}", + context.User.Identity?.Name); + context.Fail(new AuthorizationFailureReason(this, "missing_rol_claim")); + return; + } + + // 3. Load permissions assigned to this role — no cache (UDT-006 D1) + var permisos = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo); + var permisoCodes = permisos.Select(p => p.Codigo).ToHashSet(StringComparer.Ordinal); + + // 4. OR semantics — any single match is enough + var matched = requirement.PermissionCodes + .FirstOrDefault(code => permisoCodes.Contains(code)); + + if (matched is not null) + { + context.Succeed(requirement); + return; + } + + // 5. Stash required permission for ForbiddenProblemDetailsHandler (Batch 3) + if (context.Resource is HttpContext httpContext) + { + httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0]; + } + + context.Fail(new AuthorizationFailureReason(this, + $"missing_permission:{string.Join('|', requirement.PermissionCodes)}")); + } +} diff --git a/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs b/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs new file mode 100644 index 0000000..b62a48c --- /dev/null +++ b/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SIGCM2.Api.Authorization; + +/// +/// Authorization attribute that requires the authenticated user to have at least ONE +/// of the declared permission codes assigned to their role (OR semantics). +/// Implements IAuthorizationRequirementData (.NET 8+) so ASP.NET Core builds the policy +/// on-the-fly from GetRequirements() — no AddPolicy() registration needed. +/// +/// +/// // Single permission +/// [RequirePermission("administracion:usuarios:gestionar")] +/// +/// // Multiple — OR semantics: any single match grants access +/// [RequirePermission("ventas:contado:crear", "ventas:ctacte:crear")] +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class RequirePermissionAttribute + : AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData +{ + /// Permission codes required (OR semantics — at least one must match). + public string[] PermissionCodes { get; } + + public RequirePermissionAttribute(params string[] permissionCodes) + { + if (permissionCodes is null || permissionCodes.Length == 0) + throw new ArgumentException("At least one permission code is required.", nameof(permissionCodes)); + + PermissionCodes = permissionCodes; + } + + /// + public IEnumerable GetRequirements() => new[] { this }; +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index dfe90f5..545deed 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -1,5 +1,7 @@ +using Microsoft.AspNetCore.Authorization; using Serilog; using Scalar.AspNetCore; +using SIGCM2.Api.Authorization; using SIGCM2.Application; using SIGCM2.Infrastructure; using SIGCM2.Api.Filters; @@ -21,6 +23,10 @@ builder.Host.UseSerilog((ctx, lc) => lc builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI +builder.Services.AddAuthorization(); +builder.Services.AddScoped(); + // Controllers with exception filter builder.Services.AddControllers(opts => { diff --git a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs new file mode 100644 index 0000000..408bcb7 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs @@ -0,0 +1,246 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Api.Tests.Authorization; + +/// +/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006). +/// Tests isolated from DB: IRolPermisoRepository is mocked via NSubstitute. +/// +public sealed class PermissionAuthorizationHandlerTests +{ + private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); + private readonly PermissionAuthorizationHandler _handler; + + public PermissionAuthorizationHandlerTests() + { + _handler = new PermissionAuthorizationHandler( + _rolPermisoRepo, + NullLogger.Instance); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue) + { + var identity = new ClaimsIdentity( + new[] { new Claim("rol", rolValue) }, + authenticationType: "TestAuth"); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal AuthenticatedUserWithoutRolClaim() + { + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "someuser") }, + authenticationType: "TestAuth"); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal AnonymousUser() + { + return new ClaimsPrincipal(new ClaimsIdentity()); // not authenticated + } + + private static Permiso MakePermiso(int id, string codigo) => + Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow); + + private static AuthorizationHandlerContext MakeContext( + ClaimsPrincipal user, + RequirePermissionAttribute requirement, + HttpContext? httpContext = null) + { + var ctx = httpContext ?? new DefaultHttpContext(); + return new AuthorizationHandlerContext( + requirements: new[] { requirement }, + user: user, + resource: ctx); + } + + // ── B-01-01: Usuario con permiso requerido → Succeed ───────────────────── + + [Fact] + public async Task HandleAsync_Succeeds_WhenUserHasRequiredPermission() + { + // Arrange + var user = AuthenticatedUserWithRol("admin"); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any()) + .Returns(new List { MakePermiso(1, "administracion:usuarios:gestionar") } + .AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + // ── B-01-02: Usuario sin el permiso requerido → Fail ────────────────────── + + [Fact] + public async Task HandleAsync_Fails_WhenUserLacksPermission() + { + // Arrange + var user = AuthenticatedUserWithRol("cajero"); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + // ── B-01-03: Multi-permiso OR — tiene uno de los dos → Succeed ──────────── + + [Fact] + public async Task HandleAsync_Succeeds_WhenAnyOfMultiplePermissions_OR() + { + // Arrange + var user = AuthenticatedUserWithRol("cajero"); + var requirement = new RequirePermissionAttribute( + "ventas:contado:crear", + "administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(new List + { + MakePermiso(10, "ventas:contado:crear"), + MakePermiso(11, "ventas:contado:cobrar"), + }.AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + // ── B-01-04: Multi-permiso OR — no tiene ninguno → Fail ─────────────────── + + [Fact] + public async Task HandleAsync_Fails_WhenNoneOfMultiplePermissions() + { + // Arrange + var user = AuthenticatedUserWithRol("cajero"); + var requirement = new RequirePermissionAttribute( + "administracion:usuarios:gestionar", + "administracion:roles:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + // ── B-01-05: Claim "rol" ausente → Fail; repo nunca llamado ─────────────── + + [Fact] + public async Task HandleAsync_Fails_WhenRolClaimMissing() + { + // Arrange + var user = AuthenticatedUserWithoutRolClaim(); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + // Repo must NOT be called when claim is absent + await _rolPermisoRepo.DidNotReceive() + .GetByRolCodigoAsync(Arg.Any(), Arg.Any()); + } + + // ── B-01-06: Repo devuelve lista vacía → Fail ───────────────────────────── + + [Fact] + public async Task HandleAsync_Fails_WhenRoleHasNoPermissions() + { + // Arrange + var user = AuthenticatedUserWithRol("reportes"); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("reportes", Arg.Any()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + // ── B-01-07: Rol no existe en RolPermiso (mismo caso que lista vacía) ────── + + [Fact] + public async Task HandleAsync_Fails_WhenRoleDoesNotExistInRolPermisoTable() + { + // Arrange + var user = AuthenticatedUserWithRol("rol_fantasma"); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("rol_fantasma", Arg.Any()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + var context = MakeContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + // ── B-01-08: Fail stashea RequiredPermission en HttpContext.Items ────────── + + [Fact] + public async Task HandleAsync_StashesRequiredPermission_InHttpContextItems_OnFail() + { + // Arrange + var user = AuthenticatedUserWithRol("cajero"); + var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); + + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(new List { MakePermiso(10, "ventas:contado:crear") } + .AsReadOnly() as IReadOnlyList); + + var httpContext = new DefaultHttpContext(); + var context = MakeContext(user, requirement, httpContext); + + // Act + await _handler.HandleAsync(context); + + // Assert — context.Resource is the HttpContext where items are stashed + Assert.False(context.HasSucceeded); + Assert.Equal("administracion:usuarios:gestionar", httpContext.Items["RequiredPermission"]); + } +} From 4866c4f21f17ab3414df3ef7de45bd7a963d952d Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:27:36 -0300 Subject: [PATCH 3/9] feat(api): ForbiddenProblemDetailsHandler 403 shape [UDT-006] --- .../ForbiddenProblemDetailsHandler.cs | 58 +++++++++++++++++++ src/api/SIGCM2.Api/Program.cs | 1 + 2 files changed, 59 insertions(+) create mode 100644 src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs diff --git a/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs b/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs new file mode 100644 index 0000000..23d2eab --- /dev/null +++ b/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; + +namespace SIGCM2.Api.Authorization; + +/// +/// Custom IAuthorizationMiddlewareResultHandler that emits a structured ProblemDetails +/// response for 403 Forbidden outcomes (authenticated user, missing permission). +/// +/// For 401 Unauthorized and successful outcomes, delegates to the default handler +/// so the existing JWT Bearer challenge flow is unaffected (REQ-B-07). +/// +/// Registered as singleton in Program.cs — depends only on framework services. +/// +public sealed class ForbiddenProblemDetailsHandler : IAuthorizationMiddlewareResultHandler +{ + private static readonly AuthorizationMiddlewareResultHandler DefaultHandler = new(); + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + // Only intercept 403s for authenticated users. + // If the user is not authenticated, the 401 challenge is handled by JwtBearer (REQ-B-07). + if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true) + { + var requiredPermission = context.Items["RequiredPermission"] as string; + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/problem+json; charset=utf-8"; + + var problem = new + { + type = "https://sigcm2.local/errors/forbidden", + title = "Acceso denegado", + status = 403, + detail = "No tenés el permiso requerido para ejecutar esta acción.", + permisoRequerido = requiredPermission, + }; + + await context.Response.WriteAsync( + JsonSerializer.Serialize(problem, SerializerOptions)); + + return; + } + + // Delegate 401 challenges and successful outcomes to the default handler + await DefaultHandler.HandleAsync(next, context, policy, authorizeResult); + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 545deed..313fe60 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddInfrastructure(builder.Configuration); // Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI builder.Services.AddAuthorization(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Controllers with exception filter builder.Services.AddControllers(opts => From 0218d8d37142d59390be068d4696369eadfeed26 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:34:32 -0300 Subject: [PATCH 4/9] feat(api): migrar controllers admin a RequirePermission [UDT-006] --- .../V007__add_admin_permissions_udt006.sql | 42 +++++++++++ .../Controllers/PermisosController.cs | 16 +++-- .../SIGCM2.Api/Controllers/RolesController.cs | 3 +- .../Controllers/UsuariosController.cs | 3 +- .../Permisos/PermisosEndpointTests.cs | 69 +++++++++++++++++-- .../Roles/RolesEndpointTests.cs | 47 +++++++++++++ .../Usuarios/CreateUsuarioEndpointTests.cs | 50 ++++++++++++++ .../Integration/PermisoRepositoryTests.cs | 6 +- .../Integration/RolPermisoRepositoryTests.cs | 6 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 16 +++-- 10 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 database/migrations/V007__add_admin_permissions_udt006.sql diff --git a/database/migrations/V007__add_admin_permissions_udt006.sql b/database/migrations/V007__add_admin_permissions_udt006.sql new file mode 100644 index 0000000..9816f50 --- /dev/null +++ b/database/migrations/V007__add_admin_permissions_udt006.sql @@ -0,0 +1,42 @@ +-- V007__add_admin_permissions_udt006.sql +-- Agrega 3 permisos administrativos requeridos por UDT-006 (middleware de autorización RBAC). +-- Los 3 nuevos permisos se asignan al rol 'admin' inmediatamente. +-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración. +-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Agregar los 3 permisos nuevos al catálogo (idempotente via MERGE) +MERGE dbo.Permiso AS t +USING (VALUES + ('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'), + ('administracion:roles_permisos:gestionar', N'Gestionar asignación de permisos', N'Asignar y revocar permisos por rol', 'administracion'), + ('administracion:permisos:ver', N'Ver catálogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion') +) AS s (Codigo, Nombre, Descripcion, Modulo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +-- Asignar los 3 nuevos permisos al rol 'admin' (idempotente via MERGE) +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('admin', 'administracion:roles:gestionar'), + ('admin', 'administracion:roles_permisos:gestionar'), + ('admin', 'administracion:permisos:ver') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); +GO + +PRINT 'V007: 3 permisos administracion:roles|roles-permisos|permisos agregados al catalogo y asignados a admin.'; +GO diff --git a/src/api/SIGCM2.Api/Controllers/PermisosController.cs b/src/api/SIGCM2.Api/Controllers/PermisosController.cs index 13bc6c4..5d1a857 100644 --- a/src/api/SIGCM2.Api/Controllers/PermisosController.cs +++ b/src/api/SIGCM2.Api/Controllers/PermisosController.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Dtos; @@ -9,9 +10,13 @@ using SIGCM2.Application.Permisos.List; namespace SIGCM2.Api.Controllers; +/// +/// Permisos controller — granular permission per method (UDT-006). +/// [Authorize] at class level requires a valid JWT; each method declares its specific permission. +/// [ApiController] [Route("api/v1")] -[Authorize(Roles = "admin")] +[Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz public sealed class PermisosController : ControllerBase { private readonly IDispatcher _dispatcher; @@ -28,8 +33,9 @@ public sealed class PermisosController : ControllerBase _getRolPermisosValidator = getRolPermisosValidator; } - /// Lists all permisos in the canonical catalog. Requires admin role. + /// Lists all permisos in the canonical catalog. [HttpGet("permisos")] + [RequirePermission("administracion:permisos:ver")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase return Ok(result); } - /// Gets all permisos assigned to a rol. Requires admin role. + /// Gets all permisos assigned to a rol. [HttpGet("roles/{codigo}/permisos")] + [RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase /// /// Replace-set: replaces the full permiso assignment for a rol. - /// Returns the updated permiso set (200). Requires admin role. + /// Returns the updated permiso set (200). /// [HttpPut("roles/{codigo}/permisos")] + [RequirePermission("administracion:roles_permisos:gestionar")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] diff --git a/src/api/SIGCM2.Api/Controllers/RolesController.cs b/src/api/SIGCM2.Api/Controllers/RolesController.cs index 6329828..bf48e3d 100644 --- a/src/api/SIGCM2.Api/Controllers/RolesController.cs +++ b/src/api/SIGCM2.Api/Controllers/RolesController.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Roles.Create; using SIGCM2.Application.Roles.Deactivate; @@ -13,7 +14,7 @@ namespace SIGCM2.Api.Controllers; [ApiController] [Route("api/v1/roles")] -[Authorize(Roles = "admin")] +[RequirePermission("administracion:roles:gestionar")] public sealed class RolesController : ControllerBase { private readonly IDispatcher _dispatcher; diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index 37a1e65..f9279b7 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Usuarios.Create; @@ -8,7 +9,7 @@ namespace SIGCM2.Api.Controllers; [ApiController] [Route("api/v1/users")] -[Authorize(Roles = "admin")] +[RequirePermission("administracion:usuarios:gestionar")] public sealed class UsuariosController : ControllerBase { private readonly IDispatcher _dispatcher; diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index ac2fd0c..efdbbca 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos — catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With18Items() + public async Task GetPermisos_WithAdmin_Returns200With21Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -138,7 +138,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - Assert.Equal(18, list.GetArrayLength()); + // V007 (UDT-006) adds 3 new admin permisos → 21 total + Assert.Equal(21, list.GetArrayLength()); } [Fact] @@ -181,7 +182,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With18Items() + public async Task GetRolPermisos_AdminRol_Returns200With21Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -189,7 +190,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - Assert.Equal(18, list.GetArrayLength()); + // V007 (UDT-006) adds 3 new admin permisos → 21 total + Assert.Equal(21, list.GetArrayLength()); } [Fact] @@ -424,4 +426,63 @@ public sealed class PermisosEndpointTests : IAsyncLifetime await DeleteUsuarioIfExistsAsync(username); } } + + // ── UDT-006: 403 ProblemDetails shape ───────────────────────────────────── + + [Fact] + public async Task GetPermisos_WithCajeroToken_Returns403WithProblemDetailsShape() + { + const string username = "udt006_permisos_403_cajero"; + try + { + var token = await CreateNonAdminUserAndGetTokenAsync(username); + using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? ""); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(403, json.GetProperty("status").GetInt32()); + Assert.Equal("Acceso denegado", json.GetProperty("title").GetString()); + Assert.True(json.TryGetProperty("permisoRequerido", out var perm), + "Response must contain 'permisoRequerido'"); + // GET /permisos migra a administracion:permisos:ver + Assert.Equal("administracion:permisos:ver", perm.GetString()); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + [Fact] + public async Task PutRolPermisos_WithCajeroToken_Returns403WithProblemDetailsShape() + { + const string username = "udt006_put_permisos_403"; + try + { + var token = await CreateNonAdminUserAndGetTokenAsync(username); + using var req = BuildRequest( + HttpMethod.Put, + "/api/v1/roles/cajero/permisos", + new { codigos = new[] { "ventas:contado:crear" } }, + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? ""); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(403, json.GetProperty("status").GetInt32()); + Assert.True(json.TryGetProperty("permisoRequerido", out var perm), + "Response must contain 'permisoRequerido'"); + // PUT /roles/{c}/permisos migra a administracion:roles_permisos:gestionar + Assert.Equal("administracion:roles_permisos:gestionar", perm.GetString()); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } } diff --git a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs index f32011d..856b011 100644 --- a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs @@ -350,4 +350,51 @@ public sealed class RolesEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); } + // ── UDT-006: 403 ProblemDetails shape ──────────────────────────────────── + + [Fact] + public async Task GetRoles_WithCajeroToken_Returns403WithProblemDetailsShape() + { + const string username = "udt006_roles_403_cajero"; + try + { + var token = await CreateCajeroTokenAsync(username); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token)); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? ""); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(403, json.GetProperty("status").GetInt32()); + Assert.Equal("Acceso denegado", json.GetProperty("title").GetString()); + Assert.True(json.TryGetProperty("permisoRequerido", out var perm), + "Response must contain 'permisoRequerido'"); + // RolesController migra a administracion:roles:gestionar + Assert.Equal("administracion:roles:gestionar", perm.GetString()); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // Helper: create cajero user via SQL and return token + private async Task CreateCajeroTokenAsync(string username) + { + var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username, + password = "Secure1234!", + nombre = "Cajero", + apellido = "Test", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkResp = await _client.SendAsync(mkUser); + if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}"); + return await GetBearerTokenAsync(username, "Secure1234!"); + } + } diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 1fa0570..9d89b3a 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -388,6 +388,56 @@ public sealed class CreateUsuarioEndpointTests : IAsyncLifetime } } + // --------------------------------------------------------------------------- + // UDT-006 Scenario: 403 con ProblemDetails shape — token cajero sin permiso administracion:usuarios:gestionar + // --------------------------------------------------------------------------- + [Fact] + public async Task CreateUsuario_WithCajeroRole_Returns403WithProblemDetailsShape() + { + const string username = "udt006_403_shape_test"; + try + { + var token = await CreateCajeroTokenAsync(username); + using var request = BuildRequest(HttpMethod.Post, Endpoint, ValidCreateBody("shape_target"), token); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + // Content-Type must be application/problem+json + Assert.Contains("problem+json", response.Content.Headers.ContentType?.MediaType ?? ""); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(403, json.GetProperty("status").GetInt32()); + Assert.Equal("Acceso denegado", json.GetProperty("title").GetString()); + Assert.True(json.TryGetProperty("permisoRequerido", out var perm), + "Response must contain 'permisoRequerido'"); + Assert.Equal("administracion:usuarios:gestionar", perm.GetString()); + } + finally + { + await DeleteUsuarioAsync(username); + } + } + + // Helper: create a cajero user and return its token + private async Task CreateCajeroTokenAsync(string username) + { + var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var mkUser = BuildRequest(HttpMethod.Post, Endpoint, new + { + username, + password = "Secure1234!", + nombre = "Cajero", + apellido = "Test", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkResp = await _client.SendAsync(mkUser); + if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}"); + return await GetBearerTokenAsync(username, "Secure1234!"); + } + // --------------------------------------------------------------------------- // Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo // --------------------------------------------------------------------------- diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index cb54042..d3b7653 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -74,12 +74,12 @@ public class PermisoRepositoryTests : IAsyncLifetime // ── ListAsync ──────────────────────────────────────────────────────────── [Fact] - public async Task ListAsync_Returns18CanonicalSeeds() + public async Task ListAsync_Returns21CanonicalSeeds() { var list = await _repository.ListAsync(); - // V005 seeds exactly 18 canonical permisos - Assert.Equal(18, list.Count); + // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos = 21 total + Assert.Equal(21, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index f38e405..acf0335 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -174,12 +174,12 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [Fact] - public async Task GetByRolCodigoAsync_Admin_Returns18Permisos() + public async Task GetByRolCodigoAsync_Admin_Returns21Permisos() { - // admin has all 18 permisos assigned in V006 seed + // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) = 21 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(18, permisos.Count); + Assert.Equal(21, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index dc8b062..b2cbd33 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -103,10 +103,14 @@ public sealed class SqlTestFixture : IAsyncLifetime ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), - ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), - ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), - ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), - ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion') + ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), + ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), + ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), + ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion'), + -- V007 (UDT-006): permisos administrativos RBAC + ('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'), + ('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'), + ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -142,6 +146,10 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:tarifarios:gestionar'), ('admin', 'administracion:medios:gestionar'), ('admin', 'administracion:auditoria:ver'), + -- V007 (UDT-006): permisos administrativos RBAC para admin + ('admin', 'administracion:roles:gestionar'), + ('admin', 'administracion:roles_permisos:gestionar'), + ('admin', 'administracion:permisos:ver'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), From 2efd5e2fdbadc9e20c2554fb8503880762e7f144 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:39:18 -0300 Subject: [PATCH 5/9] feat(web): authStore + useLogin persisten permisos [UDT-006] --- src/web/src/features/auth/api/authApi.ts | 1 + src/web/src/features/auth/hooks/useLogin.ts | 1 + src/web/src/stores/authStore.ts | 1 + .../tests/features/auth/LoginPage.test.tsx | 2 +- .../src/tests/features/auth/authApi.test.ts | 1 + .../src/tests/features/auth/useLogin.test.ts | 96 +++++++++++++++++++ src/web/src/tests/stores/authStore.test.ts | 69 +++++++++++-- 7 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 src/web/src/tests/features/auth/useLogin.test.ts diff --git a/src/web/src/features/auth/api/authApi.ts b/src/web/src/features/auth/api/authApi.ts index 0b7fe43..0955bfc 100644 --- a/src/web/src/features/auth/api/authApi.ts +++ b/src/web/src/features/auth/api/authApi.ts @@ -9,6 +9,7 @@ export interface LoginResponseDto { username: string nombre: string rol: string + permisos: string[] } } diff --git a/src/web/src/features/auth/hooks/useLogin.ts b/src/web/src/features/auth/hooks/useLogin.ts index a637832..b4d1f1b 100644 --- a/src/web/src/features/auth/hooks/useLogin.ts +++ b/src/web/src/features/auth/hooks/useLogin.ts @@ -19,6 +19,7 @@ export function useLogin() { username: data.usuario.username, nombre: data.usuario.nombre, rol: data.usuario.rol, + permisos: data.usuario.permisos ?? [], }, accessToken: data.accessToken, refreshToken: data.refreshToken, diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts index cfa8a68..7e7ebec 100644 --- a/src/web/src/stores/authStore.ts +++ b/src/web/src/stores/authStore.ts @@ -6,6 +6,7 @@ export interface AuthUser { username: string nombre: string rol: string + permisos: string[] } interface SetAuthPayload { diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx index 8be4e3d..d99c99b 100644 --- a/src/web/src/tests/features/auth/LoginPage.test.tsx +++ b/src/web/src/tests/features/auth/LoginPage.test.tsx @@ -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' }, + usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] }, } const server = setupServer( diff --git a/src/web/src/tests/features/auth/authApi.test.ts b/src/web/src/tests/features/auth/authApi.test.ts index fb4780c..687e6f6 100644 --- a/src/web/src/tests/features/auth/authApi.test.ts +++ b/src/web/src/tests/features/auth/authApi.test.ts @@ -14,6 +14,7 @@ const mockLoginResponse = { username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], }, } diff --git a/src/web/src/tests/features/auth/useLogin.test.ts b/src/web/src/tests/features/auth/useLogin.test.ts new file mode 100644 index 0000000..e8ac80c --- /dev/null +++ b/src/web/src/tests/features/auth/useLogin.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { renderHook, act, 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 { useLogin } from '../../../features/auth/hooks/useLogin' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockLoginResponseWithPermisos = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + refreshToken: 'refresh-token-abc', + expiresIn: 3600, + usuario: { + id: 1, + username: 'admin', + nombre: 'Admin Sistema', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + }, +} + +const mockLoginResponseEmptyPermisos = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + refreshToken: 'refresh-token-abc', + expiresIn: 3600, + usuario: { + id: 2, + username: 'cajero', + nombre: 'Cajero Test', + rol: 'cajero', + permisos: [], + }, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() +}) +afterAll(() => server.close()) + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) +} + +describe('useLogin — permisos propagation', () => { + it('F-login-01: response con permisos → store.user.permisos poblado', 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?.permisos).toContain('administracion:usuarios:gestionar') + expect(state.user?.permisos).toContain('administracion:roles:gestionar') + expect(state.user?.permisos).toHaveLength(2) + }) + + it('F-login-02: response con permisos vacíos → store.user.permisos = []', async () => { + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => + HttpResponse.json(mockLoginResponseEmptyPermisos, { status: 200 }), + ), + ) + + const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }) + + act(() => { + result.current.mutate({ username: 'cajero', password: 'password' }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const state = useAuthStore.getState() + expect(state.user?.permisos).toEqual([]) + expect(state.user?.permisos).not.toBeNull() + }) +}) diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index fdc3b95..ba6b45f 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -28,7 +28,7 @@ describe('authStore', () => { describe('setAuth', () => { it('stores user and accessToken in state', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -43,7 +43,7 @@ describe('authStore', () => { it('persists auth data to localStorage under auth-storage key', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -61,7 +61,7 @@ describe('authStore', () => { it('setAuth_persistsRefreshTokenAndExpiresAt', () => { const before = Date.now() const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'access-token-abc', refreshToken: 'opaque-refresh-xyz', expiresIn: 3600, @@ -83,12 +83,63 @@ describe('authStore', () => { expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz') expect(parsed.state.expiresAt).toBeGreaterThan(0) }) + + it('F-04-01: setAuth con permisos → user.permisos contiene los valores', () => { + const payload = { + user: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.permisos).toContain('administracion:usuarios:gestionar') + expect(state.user?.permisos).toContain('administracion:roles:gestionar') + expect(state.user?.permisos).toHaveLength(2) + }) + + 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: [] }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.permisos).toEqual([]) + expect(state.user?.permisos).not.toBeNull() + }) }) 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'] }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + }) + + useAuthStore.getState().clearAuth() + + const state = useAuthStore.getState() + expect(state.user).toBeNull() + }) + it('clearAuth_removesAllFields', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -106,7 +157,7 @@ describe('authStore', () => { describe('updateAccess', () => { it('updateAccess_updatesOnlyTokens_preservesUser', () => { - const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' } + const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] } useAuthStore.getState().setAuth({ user: originalUser, accessToken: 'old-access', @@ -130,7 +181,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' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -150,7 +201,7 @@ describe('authStore', () => { it('logout_apiFails_stillClearsAuth', async () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -175,7 +226,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' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600, @@ -190,7 +241,7 @@ describe('authStore', () => { it('clearAuth removes auth-storage from localStorage', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600, From 8935115da9c056ac201fc27534fae057ce3b0324 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:40:23 -0300 Subject: [PATCH 6/9] feat(web): usePermission + CanPerform [UDT-006] --- src/web/src/components/auth/CanPerform.tsx | 19 ++++ .../src/features/auth/hooks/usePermission.ts | 15 +++ .../tests/features/auth/CanPerform.test.tsx | 103 ++++++++++++++++++ .../tests/features/auth/usePermission.test.ts | 96 ++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/web/src/components/auth/CanPerform.tsx create mode 100644 src/web/src/features/auth/hooks/usePermission.ts create mode 100644 src/web/src/tests/features/auth/CanPerform.test.tsx create mode 100644 src/web/src/tests/features/auth/usePermission.test.ts diff --git a/src/web/src/components/auth/CanPerform.tsx b/src/web/src/components/auth/CanPerform.tsx new file mode 100644 index 0000000..02cc7cd --- /dev/null +++ b/src/web/src/components/auth/CanPerform.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' +import { usePermission } from '@/features/auth/hooks/usePermission' + +interface CanPerformProps { + /** Permission code or array of codes (OR: at least one must match). */ + permission: string | string[] + /** Rendered when user lacks the permission. Defaults to null. */ + fallback?: ReactNode + children: ReactNode +} + +/** + * Renders children if the authenticated user has the required permission(s). + * When permission is an array, OR semantics apply — one match is sufficient. + * Renders fallback (or null) otherwise. + */ +export function CanPerform({ permission, fallback = null, children }: CanPerformProps) { + return usePermission(permission) ? <>{children} : <>{fallback} +} diff --git a/src/web/src/features/auth/hooks/usePermission.ts b/src/web/src/features/auth/hooks/usePermission.ts new file mode 100644 index 0000000..b0f050b --- /dev/null +++ b/src/web/src/features/auth/hooks/usePermission.ts @@ -0,0 +1,15 @@ +import { useAuthStore } from '@/stores/authStore' + +/** + * Returns true if the authenticated user has at least one of the given permission codes. + * OR semantics when passing an array — at least one match suffices. + * Returns false when the user is not authenticated or has no matching permission. + */ +export function usePermission(code: string | string[]): boolean { + // Select the user directly to avoid creating a new array reference on every render + // when user is null (which would cause infinite re-renders in React 19). + const user = useAuthStore((s) => s.user) + const permisos = user?.permisos ?? [] + const wanted = Array.isArray(code) ? code : [code] + return wanted.some((c) => permisos.includes(c)) +} diff --git a/src/web/src/tests/features/auth/CanPerform.test.tsx b/src/web/src/tests/features/auth/CanPerform.test.tsx new file mode 100644 index 0000000..d6f6b5e --- /dev/null +++ b/src/web/src/tests/features/auth/CanPerform.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useAuthStore } from '../../../stores/authStore' +import { CanPerform } from '../../../components/auth/CanPerform' + +beforeEach(() => { + useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null }) +}) + +describe('CanPerform', () => { + it('F-02-01: usuario con permiso → renderiza children', () => { + useAuthStore.setState({ + user: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + }, + }) + + render( + + Acción + , + ) + + expect(screen.getByText('Acción')).toBeInTheDocument() + }) + + it('F-02-02: usuario sin permiso → no renderiza children (null)', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + render( + + Acción + , + ) + + expect(screen.queryByText('Acción')).not.toBeInTheDocument() + }) + + it('F-02-03: sin user → no renderiza children', () => { + useAuthStore.setState({ user: null }) + + render( + + Acción + , + ) + + expect(screen.queryByText('Acción')).not.toBeInTheDocument() + }) + + it('F-02-04: sin permiso pero con fallback → renderiza fallback, NO children', () => { + useAuthStore.setState({ + user: { + id: 3, + username: 'reportes', + nombre: 'Reportes', + rol: 'reportes', + permisos: [], + }, + }) + + render( + Sin acceso}> + Acción + , + ) + + expect(screen.getByText('Sin acceso')).toBeInTheDocument() + expect(screen.queryByText('Acción')).not.toBeInTheDocument() + }) + + it('array permission OR: renderiza si tiene alguno', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + render( + + Acción + , + ) + + expect(screen.getByText('Acción')).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/auth/usePermission.test.ts b/src/web/src/tests/features/auth/usePermission.test.ts new file mode 100644 index 0000000..e5bfdb8 --- /dev/null +++ b/src/web/src/tests/features/auth/usePermission.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useAuthStore } from '../../../stores/authStore' +import { usePermission } from '../../../features/auth/hooks/usePermission' + +beforeEach(() => { + useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null }) +}) + +describe('usePermission', () => { + it('F-01-01: user con permiso exacto → true', () => { + useAuthStore.setState({ + user: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + }, + }) + + const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar')) + expect(result.current).toBe(true) + }) + + it('F-01-02: user sin ese permiso → false', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar')) + expect(result.current).toBe(false) + }) + + it('F-01-03: user con permisos vacíos → false', () => { + useAuthStore.setState({ + user: { + id: 3, + username: 'reportes', + nombre: 'Reportes', + rol: 'reportes', + permisos: [], + }, + }) + + const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar')) + expect(result.current).toBe(false) + }) + + it('F-01-04: sin user (null) → false', () => { + useAuthStore.setState({ user: null }) + + const { result } = renderHook(() => usePermission('administracion:usuarios:gestionar')) + expect(result.current).toBe(false) + }) + + it('array input OR: true si alguno de los permisos hace match', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + const { result } = renderHook(() => + usePermission(['ventas:contado:crear', 'administracion:usuarios:gestionar']), + ) + expect(result.current).toBe(true) + }) + + it('array input OR: false si ninguno hace match', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + const { result } = renderHook(() => + usePermission(['administracion:usuarios:gestionar', 'administracion:roles:gestionar']), + ) + expect(result.current).toBe(false) + }) +}) From f6cdd7650b438af64aac08f2df9d44750d4b9c10 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:41:39 -0300 Subject: [PATCH 7/9] =?UTF-8?q?feat(web):=20ProtectedRoute=20extra=C3=ADdo?= =?UTF-8?q?=20+=20router=20migrado=20+=20CreateUserPage=20cleanup=20[UDT-0?= =?UTF-8?q?06]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/routing/ProtectedRoute.tsx | 48 +++ .../features/users/pages/CreateUserPage.tsx | 8 - src/web/src/router.tsx | 24 +- .../features/auth/ProtectedRoute.test.tsx | 280 ++++++++++++++++++ 4 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 src/web/src/components/routing/ProtectedRoute.tsx create mode 100644 src/web/src/tests/features/auth/ProtectedRoute.test.tsx diff --git a/src/web/src/components/routing/ProtectedRoute.tsx b/src/web/src/components/routing/ProtectedRoute.tsx new file mode 100644 index 0000000..b77cf3c --- /dev/null +++ b/src/web/src/components/routing/ProtectedRoute.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' + +export interface ProtectedRouteProps { + children: ReactNode + /** OR semantics — user's role must be in this list (at least one match). */ + requiredRoles?: string[] + /** OR semantics — user must have at least one of these permission codes. */ + requiredPermissions?: string[] +} + +/** + * Wraps a route with authentication and optional authorization guards. + * + * Guard order: + * 1. No user → redirect to /login + * 2. requiredRoles provided and user.rol not in list → redirect to / + * 3. requiredPermissions provided and user has none of them → redirect to / + * 4. All checks pass → render children + */ +export function ProtectedRoute({ + children, + requiredRoles, + requiredPermissions, +}: ProtectedRouteProps) { + const user = useAuthStore((s) => s.user) + + // 1. Authentication check + if (!user) { + return + } + + // 2. Role check (OR: user.rol must be included in the list) + if (requiredRoles && requiredRoles.length > 0 && !requiredRoles.includes(user.rol)) { + return + } + + // 3. Permission check (OR: user must have at least one of the required codes) + if (requiredPermissions && requiredPermissions.length > 0) { + const hasPermission = requiredPermissions.some((p) => user.permisos.includes(p)) + if (!hasPermission) { + return + } + } + + return <>{children} +} diff --git a/src/web/src/features/users/pages/CreateUserPage.tsx b/src/web/src/features/users/pages/CreateUserPage.tsx index a6a05de..b97076b 100644 --- a/src/web/src/features/users/pages/CreateUserPage.tsx +++ b/src/web/src/features/users/pages/CreateUserPage.tsx @@ -1,5 +1,4 @@ import { useNavigate } from 'react-router-dom' -import { useAuthStore } from '@/stores/authStore' import { UserForm } from '../components/UserForm' import { Card, @@ -12,13 +11,6 @@ import type { CreatedUserDto } from '../api/createUser' export function CreateUserPage() { const navigate = useNavigate() - const user = useAuthStore((s) => s.user) - - // Guard: only admins can access this page - if (!user || user.rol !== 'admin') { - void navigate('/', { replace: true }) - return null - } function handleSuccess(_created: CreatedUserDto) { void navigate('/') diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 5f2c352..6c10ee7 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAuthStore } from './stores/authStore' +import { ProtectedRoute } from './components/routing/ProtectedRoute' import { LoginPage } from './features/auth/pages/LoginPage' import { CreateUserPage } from './features/users/pages/CreateUserPage' import { RolesPage } from './features/roles/pages/RolesPage' @@ -10,14 +11,6 @@ import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const user = useAuthStore((s) => s.user) - if (!user) { - return - } - return <>{children} -} - function PublicRoute({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user) if (user) { @@ -52,7 +45,7 @@ export function AppRoutes() { + @@ -62,7 +55,7 @@ export function AppRoutes() { + @@ -72,7 +65,7 @@ export function AppRoutes() { + @@ -82,7 +75,7 @@ export function AppRoutes() { + @@ -92,7 +85,12 @@ export function AppRoutes() { + diff --git a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx new file mode 100644 index 0000000..8231c5a --- /dev/null +++ b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx @@ -0,0 +1,280 @@ +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 { ProtectedRoute } from '../../../components/routing/ProtectedRoute' + +// Helper components for testing +function HomePage() { + return
Home Page
+} + +function SecurePage() { + return
Secure Page
+} + +// Renders ProtectedRoute at a given path with optional navigation target capture +function renderProtected( + props: { + requiredRoles?: string[] + requiredPermissions?: string[] + children?: React.ReactNode + }, + { initialPath = '/' }: { initialPath?: string } = {}, +) { + const { children, ...routeProps } = props + return render( + + + Login Page} /> + Root} /> + + {children ?? } +
+ } + /> + + , + ) +} + +beforeEach(() => { + useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null }) +}) + +describe('ProtectedRoute', () => { + it('F-03-01: sin user → redirect a /login', () => { + useAuthStore.setState({ user: null }) + + render( + + + Login Page} /> + + +
+ } + /> + + , + ) + + expect(screen.getByText('Login Page')).toBeInTheDocument() + expect(screen.queryByText('Secure Page')).not.toBeInTheDocument() + }) + + it('F-03-02: user autenticado sin restricciones → renderiza children', () => { + useAuthStore.setState({ + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + }) + + render( + + + Login Page} /> + + +
+ } + /> + + , + ) + + expect(screen.getByText('Secure Page')).toBeInTheDocument() + }) + + it('F-03-03: requiredRoles coincide → renderiza children', () => { + useAuthStore.setState({ + user: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + }, + }) + + render( + + + Login Page} /> + Root} /> + + +
+ } + /> + + , + ) + + expect(screen.getByText('Secure Page')).toBeInTheDocument() + }) + + it('F-03-04: requiredRoles no coincide → redirect a /', () => { + useAuthStore.setState({ + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + }) + + render( + + + Login Page} /> + Root Page} /> + + +
+ } + /> + + , + ) + + expect(screen.getByText('Root Page')).toBeInTheDocument() + expect(screen.queryByText('Secure Page')).not.toBeInTheDocument() + }) + + it('F-03-05: requiredPermissions OR — user tiene uno → renderiza children', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + render( + + + Login Page} /> + Root Page} /> + + + + } + /> + + , + ) + + expect(screen.getByText('Secure Page')).toBeInTheDocument() + }) + + it('F-03-06: requiredPermissions — user no tiene ninguno → redirect a /', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + render( + + + Login Page} /> + Root Page} /> + + + + } + /> + + , + ) + + expect(screen.getByText('Root Page')).toBeInTheDocument() + expect(screen.queryByText('Secure Page')).not.toBeInTheDocument() + }) + + it('F-03-07: usuario admin puede acceder a ruta con requiredPermissions', () => { + useAuthStore.setState({ + user: { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + }, + }) + + render( + + + Login Page} /> + Root Page} /> + +
Create User Page
+ + } + /> +
+
, + ) + + expect(screen.getByText('Create User Page')).toBeInTheDocument() + }) + + it('F-03-08: usuario cajero sin permiso no puede acceder a /usuarios/nuevo → redirect a /', () => { + useAuthStore.setState({ + user: { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: ['ventas:contado:crear'], + }, + }) + + render( + + + Login Page} /> + Root Page} /> + +
Create User Page
+ + } + /> +
+
, + ) + + expect(screen.getByText('Root Page')).toBeInTheDocument() + expect(screen.queryByText('Create User Page')).not.toBeInTheDocument() + }) +}) From 96e7290fb7afc251369824402fe93dbee8900e4a Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:49:21 -0300 Subject: [PATCH 8/9] =?UTF-8?q?refactor(web):=20eliminar=20guards=20inline?= =?UTF-8?q?=20rol=20admin=20en=20p=C3=A1ginas=20de=20roles/permisos=20[UDT?= =?UTF-8?q?-006]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/permisos/pages/RolPermisosPage.tsx | 9 --------- src/web/src/features/roles/pages/EditRolPage.tsx | 7 ------- src/web/src/features/roles/pages/NewRolPage.tsx | 7 ------- src/web/src/features/roles/pages/RolesPage.tsx | 11 +---------- 4 files changed, 1 insertion(+), 33 deletions(-) diff --git a/src/web/src/features/permisos/pages/RolPermisosPage.tsx b/src/web/src/features/permisos/pages/RolPermisosPage.tsx index 19dd823..7c79c8d 100644 --- a/src/web/src/features/permisos/pages/RolPermisosPage.tsx +++ b/src/web/src/features/permisos/pages/RolPermisosPage.tsx @@ -1,6 +1,4 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { useAuthStore } from '@/stores/authStore' import { Card, CardContent, @@ -12,17 +10,10 @@ import { useRoles } from '../../roles/hooks/useRoles' import { RolPermisosEditor } from '../components/RolPermisosEditor' export function RolPermisosPage() { - const navigate = useNavigate() - const user = useAuthStore((s) => s.user) const [selectedRol, setSelectedRol] = useState(null) const { data: roles, isLoading: loadingRoles } = useRoles() - if (!user || user.rol !== 'admin') { - void navigate('/', { replace: true }) - return null - } - const rolesActivos = roles?.filter((r) => r.activo) ?? [] return ( diff --git a/src/web/src/features/roles/pages/EditRolPage.tsx b/src/web/src/features/roles/pages/EditRolPage.tsx index b5e7973..bc62937 100644 --- a/src/web/src/features/roles/pages/EditRolPage.tsx +++ b/src/web/src/features/roles/pages/EditRolPage.tsx @@ -1,5 +1,4 @@ import { useNavigate, useParams } from 'react-router-dom' -import { useAuthStore } from '@/stores/authStore' import { Card, CardContent, @@ -15,14 +14,8 @@ import { EditRolForm } from '../components/RolForm' export function EditRolPage() { const navigate = useNavigate() const { codigo } = useParams<{ codigo: string }>() - const user = useAuthStore((s) => s.user) const { data: rol, isLoading, isError } = useRol(codigo) - if (!user || user.rol !== 'admin') { - void navigate('/', { replace: true }) - return null - } - return (
diff --git a/src/web/src/features/roles/pages/NewRolPage.tsx b/src/web/src/features/roles/pages/NewRolPage.tsx index 3bcd68e..0c7ff15 100644 --- a/src/web/src/features/roles/pages/NewRolPage.tsx +++ b/src/web/src/features/roles/pages/NewRolPage.tsx @@ -1,5 +1,4 @@ import { useNavigate } from 'react-router-dom' -import { useAuthStore } from '@/stores/authStore' import { Card, CardContent, @@ -11,12 +10,6 @@ import { CreateRolForm } from '../components/RolForm' export function NewRolPage() { const navigate = useNavigate() - const user = useAuthStore((s) => s.user) - - if (!user || user.rol !== 'admin') { - void navigate('/', { replace: true }) - return null - } return (
diff --git a/src/web/src/features/roles/pages/RolesPage.tsx b/src/web/src/features/roles/pages/RolesPage.tsx index 3741377..d13e8a7 100644 --- a/src/web/src/features/roles/pages/RolesPage.tsx +++ b/src/web/src/features/roles/pages/RolesPage.tsx @@ -1,5 +1,4 @@ -import { Link, useNavigate } from 'react-router-dom' -import { useAuthStore } from '@/stores/authStore' +import { Link } from 'react-router-dom' import { Button } from '@/components/ui/button' import { Card, @@ -11,14 +10,6 @@ import { import { RolesList } from '../components/RolesList' export function RolesPage() { - const navigate = useNavigate() - const user = useAuthStore((s) => s.user) - - if (!user || user.rol !== 'admin') { - void navigate('/', { replace: true }) - return null - } - return (
From 8513e99554b434d62748b1ce9889994db2194e43 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:49:54 -0300 Subject: [PATCH 9/9] test(api): assert count 21 permisos admin post-V007 [UDT-006] --- tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index c218178..d359292 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -47,6 +47,7 @@ public class AuthControllerTests Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty"); Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty"); Assert.Equal(JsonValueKind.Array, permisos.ValueKind); + Assert.Equal(21, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error