Compare commits
9 Commits
2afac53fca
...
8513e99554
| Author | SHA1 | Date | |
|---|---|---|---|
| 8513e99554 | |||
| 96e7290fb7 | |||
| f6cdd7650b | |||
| 8935115da9 | |||
| 2efd5e2fdb | |||
| 0218d8d371 | |||
| 4866c4f21f | |||
| 58d0df601f | |||
| cdb8dcd03c |
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal file
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal file
@@ -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
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization handler for <see cref="RequirePermissionAttribute"/>.
|
||||
/// Reads the "rol" claim from the authenticated user, queries <see cref="IRolPermisoRepository"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandler
|
||||
: AuthorizationHandler<RequirePermissionAttribute>
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
||||
|
||||
public PermissionAuthorizationHandler(
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
ILogger<PermissionAuthorizationHandler> 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)}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// // Single permission
|
||||
/// [RequirePermission("administracion:usuarios:gestionar")]
|
||||
///
|
||||
/// // Multiple — OR semantics: any single match grants access
|
||||
/// [RequirePermission("ventas:contado:crear", "ventas:ctacte:crear")]
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||
public sealed class RequirePermissionAttribute
|
||||
: AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData
|
||||
{
|
||||
/// <summary>Permission codes required (OR semantics — at least one must match).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IAuthorizationRequirement> GetRequirements() => new[] { this };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Permisos controller — granular permission per method (UDT-006).
|
||||
/// [Authorize] at class level requires a valid JWT; each method declares its specific permission.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>Lists all permisos in the canonical catalog. Requires admin role.</summary>
|
||||
/// <summary>Lists all permisos in the canonical catalog.</summary>
|
||||
[HttpGet("permisos")]
|
||||
[RequirePermission("administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
@@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets all permisos assigned to a rol. Requires admin role.</summary>
|
||||
/// <summary>Gets all permisos assigned to a rol.</summary>
|
||||
[HttpGet("roles/{codigo}/permisos")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
@@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[HttpPut("roles/{codigo}/permisos")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,11 @@ 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<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ForbiddenProblemDetailsHandler>();
|
||||
|
||||
// Controllers with exception filter
|
||||
builder.Services.AddControllers(opts =>
|
||||
{
|
||||
|
||||
@@ -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<LoginCommand, LoginRes
|
||||
private readonly IRefreshTokenGenerator _refreshGenerator;
|
||||
private readonly IClientContext _clientContext;
|
||||
private readonly AuthOptions _authOptions;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||
|
||||
public LoginCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
@@ -25,7 +25,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
IRefreshTokenRepository refreshRepository,
|
||||
IRefreshTokenGenerator refreshGenerator,
|
||||
IClientContext clientContext,
|
||||
AuthOptions authOptions)
|
||||
AuthOptions authOptions,
|
||||
IRolPermisoRepository rolPermisoRepository)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
@@ -34,6 +35,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
_refreshGenerator = refreshGenerator;
|
||||
_clientContext = clientContext;
|
||||
_authOptions = authOptions;
|
||||
_rolPermisoRepository = rolPermisoRepository;
|
||||
}
|
||||
|
||||
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
||||
@@ -59,8 +61,10 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
_clientContext.Ip, _clientContext.UserAgent);
|
||||
await _refreshRepository.AddAsync(entity);
|
||||
|
||||
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson)
|
||||
?? Array.Empty<string>();
|
||||
// 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,
|
||||
|
||||
19
src/web/src/components/auth/CanPerform.tsx
Normal file
19
src/web/src/components/auth/CanPerform.tsx
Normal file
@@ -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}</>
|
||||
}
|
||||
48
src/web/src/components/routing/ProtectedRoute.tsx
Normal file
48
src/web/src/components/routing/ProtectedRoute.tsx
Normal file
@@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// 2. Role check (OR: user.rol must be included in the list)
|
||||
if (requiredRoles && requiredRoles.length > 0 && !requiredRoles.includes(user.rol)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
// 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 <Navigate to="/" replace />
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface LoginResponseDto {
|
||||
username: string
|
||||
nombre: string
|
||||
rol: string
|
||||
permisos: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
src/web/src/features/auth/hooks/usePermission.ts
Normal file
15
src/web/src/features/auth/hooks/usePermission.ts
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center py-8">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-4xl">
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
if (user) {
|
||||
@@ -52,7 +45,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<CreateUserPage />
|
||||
</ProtectedLayout>
|
||||
@@ -62,7 +55,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<RolesPage />
|
||||
</ProtectedLayout>
|
||||
@@ -72,7 +65,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<NewRolPage />
|
||||
</ProtectedLayout>
|
||||
@@ -82,7 +75,7 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/roles/:codigo/editar"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
|
||||
<ProtectedLayout>
|
||||
<EditRolPage />
|
||||
</ProtectedLayout>
|
||||
@@ -92,7 +85,12 @@ export function AppRoutes() {
|
||||
<Route
|
||||
path="/admin/permisos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute
|
||||
requiredPermissions={[
|
||||
'administracion:roles_permisos:gestionar',
|
||||
'administracion:permisos:ver',
|
||||
]}
|
||||
>
|
||||
<ProtectedLayout>
|
||||
<RolPermisosPage />
|
||||
</ProtectedLayout>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AuthUser {
|
||||
username: string
|
||||
nombre: string
|
||||
rol: string
|
||||
permisos: string[]
|
||||
}
|
||||
|
||||
interface SetAuthPayload {
|
||||
|
||||
103
src/web/src/tests/features/auth/CanPerform.test.tsx
Normal file
103
src/web/src/tests/features/auth/CanPerform.test.tsx
Normal file
@@ -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(
|
||||
<CanPerform permission="administracion:usuarios:gestionar">
|
||||
<span>Acción</span>
|
||||
</CanPerform>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CanPerform permission="administracion:usuarios:gestionar">
|
||||
<span>Acción</span>
|
||||
</CanPerform>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Acción')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('F-02-03: sin user → no renderiza children', () => {
|
||||
useAuthStore.setState({ user: null })
|
||||
|
||||
render(
|
||||
<CanPerform permission="administracion:usuarios:gestionar">
|
||||
<span>Acción</span>
|
||||
</CanPerform>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CanPerform permission="administracion:usuarios:gestionar" fallback={<span>Sin acceso</span>}>
|
||||
<span>Acción</span>
|
||||
</CanPerform>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CanPerform permission={['ventas:contado:crear', 'administracion:usuarios:gestionar']}>
|
||||
<span>Acción</span>
|
||||
</CanPerform>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Acción')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
280
src/web/src/tests/features/auth/ProtectedRoute.test.tsx
Normal file
280
src/web/src/tests/features/auth/ProtectedRoute.test.tsx
Normal file
@@ -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 <div>Home Page</div>
|
||||
}
|
||||
|
||||
function SecurePage() {
|
||||
return <div>Secure Page</div>
|
||||
}
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute {...routeProps}>
|
||||
{children ?? <SecurePage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['admin']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['admin']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
requiredPermissions={['ventas:contado:crear', 'administracion:usuarios:gestionar']}
|
||||
>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/secure']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/secure"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<SecurePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/usuarios/nuevo']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<div>Create User Page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/usuarios/nuevo']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/" element={<div>Root Page</div>} />
|
||||
<Route
|
||||
path="/usuarios/nuevo"
|
||||
element={
|
||||
<ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
|
||||
<div>Create User Page</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Root Page')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create User Page')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ const mockLoginResponse = {
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['administracion:usuarios:gestionar'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
96
src/web/src/tests/features/auth/useLogin.test.ts
Normal file
96
src/web/src/tests/features/auth/useLogin.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
96
src/web/src/tests/features/auth/usePermission.test.ts
Normal file
96
src/web/src/tests/features/auth/usePermission.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006).
|
||||
/// Tests isolated from DB: IRolPermisoRepository is mocked via NSubstitute.
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandlerTests
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||
private readonly PermissionAuthorizationHandler _handler;
|
||||
|
||||
public PermissionAuthorizationHandlerTests()
|
||||
{
|
||||
_handler = new PermissionAuthorizationHandler(
|
||||
_rolPermisoRepo,
|
||||
NullLogger<PermissionAuthorizationHandler>.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<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(1, "administracion:usuarios:gestionar") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<Permiso>
|
||||
{
|
||||
MakePermiso(10, "ventas:contado:crear"),
|
||||
MakePermiso(11, "ventas:contado:cobrar"),
|
||||
}.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>())
|
||||
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<Permiso> { MakePermiso(10, "ventas:contado:crear") }
|
||||
.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JsonElement>();
|
||||
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<string> 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!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<JsonElement>();
|
||||
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<string> 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ public class LoginCommandHandlerTests
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||
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<RefreshToken>()).Returns(1);
|
||||
|
||||
// Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben
|
||||
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Permiso>().AsReadOnly());
|
||||
|
||||
_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<Permiso>
|
||||
{
|
||||
MakePermiso(1, "administracion:usuarios:gestionar"),
|
||||
MakePermiso(2, "administracion:roles:gestionar"),
|
||||
MakePermiso(3, "administracion:permisos:ver"),
|
||||
};
|
||||
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
|
||||
.Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
// 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<CancellationToken>());
|
||||
}
|
||||
|
||||
// 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<CancellationToken>())
|
||||
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
// 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<Permiso>
|
||||
{
|
||||
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<CancellationToken>())
|
||||
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
||||
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user