Compare commits

...

9 Commits

37 changed files with 1475 additions and 96 deletions

View 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

View File

@@ -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);
}
}

View File

@@ -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)}"));
}
}

View File

@@ -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 };
}

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.Dtos;
@@ -9,9 +10,13 @@ using SIGCM2.Application.Permisos.List;
namespace SIGCM2.Api.Controllers; 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] [ApiController]
[Route("api/v1")] [Route("api/v1")]
[Authorize(Roles = "admin")] [Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz
public sealed class PermisosController : ControllerBase public sealed class PermisosController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;
@@ -28,8 +33,9 @@ public sealed class PermisosController : ControllerBase
_getRolPermisosValidator = getRolPermisosValidator; _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")] [HttpGet("permisos")]
[RequirePermission("administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase
return Ok(result); 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")] [HttpGet("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase
/// <summary> /// <summary>
/// Replace-set: replaces the full permiso assignment for a rol. /// 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> /// </summary>
[HttpPut("roles/{codigo}/permisos")] [HttpPut("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Roles.Create; using SIGCM2.Application.Roles.Create;
using SIGCM2.Application.Roles.Deactivate; using SIGCM2.Application.Roles.Deactivate;
@@ -13,7 +14,7 @@ namespace SIGCM2.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/roles")] [Route("api/v1/roles")]
[Authorize(Roles = "admin")] [RequirePermission("administracion:roles:gestionar")]
public sealed class RolesController : ControllerBase public sealed class RolesController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Create;
@@ -8,7 +9,7 @@ namespace SIGCM2.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/users")] [Route("api/v1/users")]
[Authorize(Roles = "admin")] [RequirePermission("administracion:usuarios:gestionar")]
public sealed class UsuariosController : ControllerBase public sealed class UsuariosController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Serilog; using Serilog;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using SIGCM2.Api.Authorization;
using SIGCM2.Application; using SIGCM2.Application;
using SIGCM2.Infrastructure; using SIGCM2.Infrastructure;
using SIGCM2.Api.Filters; using SIGCM2.Api.Filters;
@@ -21,6 +23,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); 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 // Controllers with exception filter
builder.Services.AddControllers(opts => builder.Services.AddControllers(opts =>
{ {

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Abstractions.Security;
@@ -17,6 +16,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
private readonly IRefreshTokenGenerator _refreshGenerator; private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientContext; private readonly IClientContext _clientContext;
private readonly AuthOptions _authOptions; private readonly AuthOptions _authOptions;
private readonly IRolPermisoRepository _rolPermisoRepository;
public LoginCommandHandler( public LoginCommandHandler(
IUsuarioRepository repository, IUsuarioRepository repository,
@@ -25,7 +25,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
IRefreshTokenRepository refreshRepository, IRefreshTokenRepository refreshRepository,
IRefreshTokenGenerator refreshGenerator, IRefreshTokenGenerator refreshGenerator,
IClientContext clientContext, IClientContext clientContext,
AuthOptions authOptions) AuthOptions authOptions,
IRolPermisoRepository rolPermisoRepository)
{ {
_repository = repository; _repository = repository;
_hasher = hasher; _hasher = hasher;
@@ -34,6 +35,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_refreshGenerator = refreshGenerator; _refreshGenerator = refreshGenerator;
_clientContext = clientContext; _clientContext = clientContext;
_authOptions = authOptions; _authOptions = authOptions;
_rolPermisoRepository = rolPermisoRepository;
} }
public async Task<LoginResponseDto> Handle(LoginCommand command) public async Task<LoginResponseDto> Handle(LoginCommand command)
@@ -59,8 +61,10 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_clientContext.Ip, _clientContext.UserAgent); _clientContext.Ip, _clientContext.UserAgent);
await _refreshRepository.AddAsync(entity); await _refreshRepository.AddAsync(entity);
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson) // UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson
?? Array.Empty<string>(); // 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( return new LoginResponseDto(
AccessToken: accessToken, AccessToken: accessToken,

View 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}</>
}

View 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}</>
}

View File

@@ -9,6 +9,7 @@ export interface LoginResponseDto {
username: string username: string
nombre: string nombre: string
rol: string rol: string
permisos: string[]
} }
} }

View File

@@ -19,6 +19,7 @@ export function useLogin() {
username: data.usuario.username, username: data.usuario.username,
nombre: data.usuario.nombre, nombre: data.usuario.nombre,
rol: data.usuario.rol, rol: data.usuario.rol,
permisos: data.usuario.permisos ?? [],
}, },
accessToken: data.accessToken, accessToken: data.accessToken,
refreshToken: data.refreshToken, refreshToken: data.refreshToken,

View 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))
}

View File

@@ -1,6 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { import {
Card, Card,
CardContent, CardContent,
@@ -12,17 +10,10 @@ import { useRoles } from '../../roles/hooks/useRoles'
import { RolPermisosEditor } from '../components/RolPermisosEditor' import { RolPermisosEditor } from '../components/RolPermisosEditor'
export function RolPermisosPage() { export function RolPermisosPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [selectedRol, setSelectedRol] = useState<string | null>(null) const [selectedRol, setSelectedRol] = useState<string | null>(null)
const { data: roles, isLoading: loadingRoles } = useRoles() const { data: roles, isLoading: loadingRoles } = useRoles()
if (!user || user.rol !== 'admin') {
void navigate('/', { replace: true })
return null
}
const rolesActivos = roles?.filter((r) => r.activo) ?? [] const rolesActivos = roles?.filter((r) => r.activo) ?? []
return ( return (

View File

@@ -1,5 +1,4 @@
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { import {
Card, Card,
CardContent, CardContent,
@@ -15,14 +14,8 @@ import { EditRolForm } from '../components/RolForm'
export function EditRolPage() { export function EditRolPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { codigo } = useParams<{ codigo: string }>() const { codigo } = useParams<{ codigo: string }>()
const user = useAuthStore((s) => s.user)
const { data: rol, isLoading, isError } = useRol(codigo) const { data: rol, isLoading, isError } = useRol(codigo)
if (!user || user.rol !== 'admin') {
void navigate('/', { replace: true })
return null
}
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Card className="w-full max-w-lg"> <Card className="w-full max-w-lg">

View File

@@ -1,5 +1,4 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { import {
Card, Card,
CardContent, CardContent,
@@ -11,12 +10,6 @@ import { CreateRolForm } from '../components/RolForm'
export function NewRolPage() { export function NewRolPage() {
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
if (!user || user.rol !== 'admin') {
void navigate('/', { replace: true })
return null
}
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">

View File

@@ -1,5 +1,4 @@
import { Link, useNavigate } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Card, Card,
@@ -11,14 +10,6 @@ import {
import { RolesList } from '../components/RolesList' import { RolesList } from '../components/RolesList'
export function RolesPage() { export function RolesPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
if (!user || user.rol !== 'admin') {
void navigate('/', { replace: true })
return null
}
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Card className="w-full max-w-4xl"> <Card className="w-full max-w-4xl">

View File

@@ -1,5 +1,4 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { UserForm } from '../components/UserForm' import { UserForm } from '../components/UserForm'
import { import {
Card, Card,
@@ -12,13 +11,6 @@ import type { CreatedUserDto } from '../api/createUser'
export function CreateUserPage() { export function CreateUserPage() {
const navigate = useNavigate() 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) { function handleSuccess(_created: CreatedUserDto) {
void navigate('/') void navigate('/')

View File

@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuthStore } from './stores/authStore' import { useAuthStore } from './stores/authStore'
import { ProtectedRoute } from './components/routing/ProtectedRoute'
import { LoginPage } from './features/auth/pages/LoginPage' import { LoginPage } from './features/auth/pages/LoginPage'
import { CreateUserPage } from './features/users/pages/CreateUserPage' import { CreateUserPage } from './features/users/pages/CreateUserPage'
import { RolesPage } from './features/roles/pages/RolesPage' import { RolesPage } from './features/roles/pages/RolesPage'
@@ -10,14 +11,6 @@ import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout' import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout' 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 }) { function PublicRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
if (user) { if (user) {
@@ -52,7 +45,7 @@ export function AppRoutes() {
<Route <Route
path="/usuarios/nuevo" path="/usuarios/nuevo"
element={ element={
<ProtectedRoute> <ProtectedRoute requiredPermissions={['administracion:usuarios:gestionar']}>
<ProtectedLayout> <ProtectedLayout>
<CreateUserPage /> <CreateUserPage />
</ProtectedLayout> </ProtectedLayout>
@@ -62,7 +55,7 @@ export function AppRoutes() {
<Route <Route
path="/admin/roles" path="/admin/roles"
element={ element={
<ProtectedRoute> <ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout> <ProtectedLayout>
<RolesPage /> <RolesPage />
</ProtectedLayout> </ProtectedLayout>
@@ -72,7 +65,7 @@ export function AppRoutes() {
<Route <Route
path="/admin/roles/nuevo" path="/admin/roles/nuevo"
element={ element={
<ProtectedRoute> <ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout> <ProtectedLayout>
<NewRolPage /> <NewRolPage />
</ProtectedLayout> </ProtectedLayout>
@@ -82,7 +75,7 @@ export function AppRoutes() {
<Route <Route
path="/admin/roles/:codigo/editar" path="/admin/roles/:codigo/editar"
element={ element={
<ProtectedRoute> <ProtectedRoute requiredPermissions={['administracion:roles:gestionar']}>
<ProtectedLayout> <ProtectedLayout>
<EditRolPage /> <EditRolPage />
</ProtectedLayout> </ProtectedLayout>
@@ -92,7 +85,12 @@ export function AppRoutes() {
<Route <Route
path="/admin/permisos" path="/admin/permisos"
element={ element={
<ProtectedRoute> <ProtectedRoute
requiredPermissions={[
'administracion:roles_permisos:gestionar',
'administracion:permisos:ver',
]}
>
<ProtectedLayout> <ProtectedLayout>
<RolPermisosPage /> <RolPermisosPage />
</ProtectedLayout> </ProtectedLayout>

View File

@@ -6,6 +6,7 @@ export interface AuthUser {
username: string username: string
nombre: string nombre: string
rol: string rol: string
permisos: string[]
} }
interface SetAuthPayload { interface SetAuthPayload {

View 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()
})
})

View File

@@ -21,7 +21,7 @@ const mockLoginResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
refreshToken: 'refresh-token-abc', refreshToken: 'refresh-token-abc',
expiresIn: 3600, 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( const server = setupServer(

View 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()
})
})

View File

@@ -14,6 +14,7 @@ const mockLoginResponse = {
username: 'admin', username: 'admin',
nombre: 'Admin', nombre: 'Admin',
rol: 'admin', rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
}, },
} }

View 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()
})
})

View 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)
})
})

View File

@@ -28,7 +28,7 @@ describe('authStore', () => {
describe('setAuth', () => { describe('setAuth', () => {
it('stores user and accessToken in state', () => { it('stores user and accessToken in state', () => {
const payload = { 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', accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token', refreshToken: 'opaque-refresh-token',
expiresIn: 3600, expiresIn: 3600,
@@ -43,7 +43,7 @@ describe('authStore', () => {
it('persists auth data to localStorage under auth-storage key', () => { it('persists auth data to localStorage under auth-storage key', () => {
const payload = { 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', accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token', refreshToken: 'opaque-refresh-token',
expiresIn: 3600, expiresIn: 3600,
@@ -61,7 +61,7 @@ describe('authStore', () => {
it('setAuth_persistsRefreshTokenAndExpiresAt', () => { it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
const before = Date.now() const before = Date.now()
const payload = { 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', accessToken: 'access-token-abc',
refreshToken: 'opaque-refresh-xyz', refreshToken: 'opaque-refresh-xyz',
expiresIn: 3600, expiresIn: 3600,
@@ -83,12 +83,63 @@ describe('authStore', () => {
expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz') expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz')
expect(parsed.state.expiresAt).toBeGreaterThan(0) 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', () => { 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', () => { it('clearAuth_removesAllFields', () => {
useAuthStore.getState().setAuth({ 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', accessToken: 'access-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
expiresIn: 3600, expiresIn: 3600,
@@ -106,7 +157,7 @@ describe('authStore', () => {
describe('updateAccess', () => { describe('updateAccess', () => {
it('updateAccess_updatesOnlyTokens_preservesUser', () => { 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({ useAuthStore.getState().setAuth({
user: originalUser, user: originalUser,
accessToken: 'old-access', accessToken: 'old-access',
@@ -130,7 +181,7 @@ describe('authStore', () => {
it('logout_callsApi_thenClearsAuth', async () => { it('logout_callsApi_thenClearsAuth', async () => {
// Set up auth state with a token so logout() will try to call the API // Set up auth state with a token so logout() will try to call the API
useAuthStore.getState().setAuth({ 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', accessToken: 'access-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
expiresIn: 3600, expiresIn: 3600,
@@ -150,7 +201,7 @@ describe('authStore', () => {
it('logout_apiFails_stillClearsAuth', async () => { it('logout_apiFails_stillClearsAuth', async () => {
useAuthStore.getState().setAuth({ 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', accessToken: 'access-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
expiresIn: 3600, expiresIn: 3600,
@@ -175,7 +226,7 @@ describe('authStore', () => {
describe('legacy logout compatibility (via clearAuth)', () => { describe('legacy logout compatibility (via clearAuth)', () => {
it('clearAuth clears user and accessToken from state', () => { it('clearAuth clears user and accessToken from state', () => {
useAuthStore.getState().setAuth({ 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', accessToken: 'some-token',
refreshToken: 'some-refresh', refreshToken: 'some-refresh',
expiresIn: 3600, expiresIn: 3600,
@@ -190,7 +241,7 @@ describe('authStore', () => {
it('clearAuth removes auth-storage from localStorage', () => { it('clearAuth removes auth-storage from localStorage', () => {
useAuthStore.getState().setAuth({ 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', accessToken: 'some-token',
refreshToken: 'some-refresh', refreshToken: 'some-refresh',
expiresIn: 3600, expiresIn: 3600,

View File

@@ -47,6 +47,7 @@ public class AuthControllerTests
Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty"); 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.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
Assert.Equal(JsonValueKind.Array, permisos.ValueKind); Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
Assert.Equal(21, permisos.GetArrayLength());
} }
// Scenario: invalid credentials return 401 with opaque error // Scenario: invalid credentials return 401 with opaque error

View File

@@ -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"]);
}
}

View File

@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ─────────────────────────────────────── // ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact] [Fact]
public async Task GetPermisos_WithAdmin_Returns200With18Items() public async Task GetPermisos_WithAdmin_Returns200With21Items()
{ {
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); 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); Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>(); 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] [Fact]
@@ -181,7 +182,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── // ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact] [Fact]
public async Task GetRolPermisos_AdminRol_Returns200With18Items() public async Task GetRolPermisos_AdminRol_Returns200With21Items()
{ {
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); 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); Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>(); 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] [Fact]
@@ -424,4 +426,63 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
await DeleteUsuarioIfExistsAsync(username); 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);
}
}
} }

View File

@@ -350,4 +350,51 @@ public sealed class RolesEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); 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!");
}
} }

View File

@@ -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 // Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -18,6 +18,7 @@ public class LoginCommandHandlerTests
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>(); private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>(); 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 AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly LoginCommandHandler _handler; private readonly LoginCommandHandler _handler;
@@ -28,16 +29,21 @@ public class LoginCommandHandlerTests
_refreshGenerator.Generate().Returns("raw_refresh_token_value"); _refreshGenerator.Generate().Returns("raw_refresh_token_value");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1); _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( _handler = new LoginCommandHandler(
_repository, _hasher, _jwtService, _repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions); _refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
_rolPermisoRepo);
} }
// Scenario: valid credentials → returns token response with usuario populated // Scenario: valid credentials → returns token response with usuario populated
[Fact] [Fact]
public async Task Handle_ValidCredentials_ReturnsTokenResponse() 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); _repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here");
@@ -55,30 +61,93 @@ public class LoginCommandHandlerTests
Assert.Equal("Admin Sys", result.Usuario.Nombre); Assert.Equal("Admin Sys", result.Usuario.Nombre);
Assert.Equal("admin", result.Usuario.Rol); Assert.Equal("admin", result.Usuario.Rol);
Assert.NotNull(result.Usuario.Permisos); 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 // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user
[Fact] [Fact]
public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser()
{ {
var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero", var cajeroPermisos = new List<Permiso>
"[\"ventas:contado:create\",\"ventas:contado:read\"]", true); {
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); _repository.GetByUsernameAsync("cajero1").Returns(usuario);
_hasher.Verify("pass123", "$2a$12$hash3").Returns(true); _hasher.Verify("pass123", "$2a$12$hash3").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); _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 command = new LoginCommand("cajero1", "pass123");
var result = await _handler.Handle(command); var result = await _handler.Handle(command);
Assert.Equal(42, result.Usuario.Id); Assert.Equal(42, result.Usuario.Id);
Assert.Equal("María González", result.Usuario.Nombre); 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.Equal(2, result.Usuario.Permisos.Length);
Assert.Contains("ventas:contado:create", result.Usuario.Permisos); Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
Assert.Contains("ventas:contado:read", 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 // Scenario: user does not exist → throws InvalidCredentialsException
[Fact] [Fact]
public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException() public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException()

View File

@@ -74,12 +74,12 @@ public class PermisoRepositoryTests : IAsyncLifetime
// ── ListAsync ──────────────────────────────────────────────────────────── // ── ListAsync ────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task ListAsync_Returns18CanonicalSeeds() public async Task ListAsync_Returns21CanonicalSeeds()
{ {
var list = await _repository.ListAsync(); var list = await _repository.ListAsync();
// V005 seeds exactly 18 canonical permisos // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos = 21 total
Assert.Equal(18, list.Count); Assert.Equal(21, list.Count);
} }
[Fact] [Fact]

View File

@@ -174,12 +174,12 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ────────────────────────────────────────────────── // ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact] [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"); var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(18, permisos.Count); Assert.Equal(21, permisos.Count);
} }
[Fact] [Fact]

View File

@@ -103,10 +103,14 @@ public sealed class SqlTestFixture : IAsyncLifetime
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
('productores:pendientes:crear', N'Cargar pendientes 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'), ('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: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: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: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: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) ) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN WHEN NOT MATCHED BY TARGET THEN
@@ -142,6 +146,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:tarifarios:gestionar'), ('admin', 'administracion:tarifarios:gestionar'),
('admin', 'administracion:medios:gestionar'), ('admin', 'administracion:medios:gestionar'),
('admin', 'administracion:auditoria:ver'), ('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:crear'),
('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'), ('cajero', 'ventas:contado:cobrar'),