using System.IdentityModel.Tokens.Jwt; 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.Application.Audit; using SIGCM2.Domain.Entities; namespace SIGCM2.Api.Tests.Authorization; /// /// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006) + SUITE-B-AUTHZ-HANDLER (UDT-009). /// Tests isolated from DB: IRolPermisoRepository and IUsuarioRepository mocked via NSubstitute. /// public sealed class PermissionAuthorizationHandlerTests { private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); private readonly IUsuarioRepository _usuarioRepo = Substitute.For(); private readonly ISecurityEventLogger _security = Substitute.For(); private readonly PermissionAuthorizationHandler _handler; public PermissionAuthorizationHandlerTests() { // Default: usuario repo returns null (no overrides) unless overridden in individual tests _usuarioRepo.GetByIdAsync(Arg.Any(), Arg.Any()) .Returns((Usuario?)null); _handler = new PermissionAuthorizationHandler( _rolPermisoRepo, _usuarioRepo, _security, NullLogger.Instance); } // ── Helpers ────────────────────────────────────────────────────────────── /// Creates an authenticated user with rol claim and sub=42 (needed by UDT-009 handler). private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue, int userId = 42) { var identity = new ClaimsIdentity( new[] { new Claim("rol", rolValue), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), }, authenticationType: "TestAuth"); return new ClaimsPrincipal(identity); } private static ClaimsPrincipal AuthenticatedUserWithoutRolClaim() { var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "someuser"), new Claim(JwtRegisteredClaimNames.Sub, "42"), }, authenticationType: "TestAuth"); return new ClaimsPrincipal(identity); } private static ClaimsPrincipal AuthenticatedUserWithoutSubClaim() { var identity = new ClaimsIdentity( new[] { new Claim("rol", "cajero") }, 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"]); } // ── UDT-009: SUITE-B-AUTHZ-HANDLER (A-01 a A-07) ──────────────────────── // A-01: Cajero sin override, endpoint requiere permiso ajeno → HasSucceeded == false [Fact] public async Task A01_Cajero_NoOverride_LacksPermission_Fails() { var user = AuthenticatedUserWithRol("cajero", userId: 42); var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(42, Arg.Any()) .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}""")); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); Assert.False(context.HasSucceeded); } // A-02: Cajero con grant del permiso requerido → HasSucceeded == true [Fact] public async Task A02_Cajero_WithGrant_RequiredPermiso_Succeeds() { var user = AuthenticatedUserWithRol("cajero", userId: 42); var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(42, Arg.Any()) .Returns(MakeUsuario(42, "cajero", """{"grant":["administracion:usuarios:gestionar"],"deny":[]}""")); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); Assert.True(context.HasSucceeded); } // A-03: Admin (tiene el permiso) + deny del permiso requerido → HasSucceeded == false [Fact] public async Task A03_Admin_WithDeny_RequiredPermiso_Fails() { var user = AuthenticatedUserWithRol("admin", userId: 1); var requirement = new RequirePermissionAttribute("administracion:permisos:ver"); _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any()) .Returns(new List { MakePermiso(21, "administracion:permisos:ver") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(1, Arg.Any()) .Returns(MakeUsuario(1, "admin", """{"grant":[],"deny":["administracion:permisos:ver"]}""")); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); Assert.False(context.HasSucceeded); } // A-04: Token sin claim 'permisos' (post-UDT-009) → handler resuelve desde DB [Fact] public async Task A04_TokenWithoutPermisosClaim_HandlerResolvesFromDB() { // Token has sub=42 but no 'permisos' claim (post-UDT-009 JWT) var user = AuthenticatedUserWithRol("cajero", userId: 42); var requirement = new RequirePermissionAttribute("ventas:contado:crear"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(42, Arg.Any()) .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}""")); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); // Handler correctly resolves from DB (no 'permisos' claim needed) Assert.True(context.HasSucceeded); await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any()); } // A-05: IUsuarioRepository.GetByIdAsync called with sub from token [Fact] public async Task A05_GetByIdAsync_CalledWithSubFromToken() { var user = AuthenticatedUserWithRol("cajero", userId: 42); var requirement = new RequirePermissionAttribute("ventas:contado:crear"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(42, Arg.Any()) .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}""")); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any()); } // A-06: sub claim absent → Fail, repo NOT called [Fact] public async Task A06_SubClaimAbsent_Fails_RepoNotCalled() { var user = AuthenticatedUserWithoutSubClaim(); var requirement = new RequirePermissionAttribute("ventas:contado:crear"); _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any(), Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); var context = MakeContext(user, requirement); await _handler.HandleAsync(context); Assert.False(context.HasSucceeded); await _usuarioRepo.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any()); } // A-07: Usuario not found in DB (null) → Fail, no exception [Fact] public async Task A07_UsuarioNotFoundInDB_FailsSafely_NoException() { var user = AuthenticatedUserWithRol("cajero", userId: 9999); var requirement = new RequirePermissionAttribute("ventas:contado:crear"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(new List { MakePermiso(10, "ventas:contado:crear") } .AsReadOnly() as IReadOnlyList); _usuarioRepo.GetByIdAsync(9999, Arg.Any()) .Returns((Usuario?)null); var context = MakeContext(user, requirement); // Should not throw — null usuario → no overrides → resolve with Empty (rol permisos only) await _handler.HandleAsync(context); // With no overrides, cajero with ventas:contado:crear should succeed Assert.True(context.HasSucceeded); } // ── helpers ─────────────────────────────────────────────────────────────── private static Usuario MakeUsuario(int id, string rol, string permisosJson) => new(id, "user" + id, "$2a$12$hash", "Test", "User", null, rol, permisosJson, true); }