UDT-006: Middleware de Autorización (RBAC enforcement) #10

Merged
dmolinari merged 9 commits from feature/UDT-006 into main 2026-04-15 20:15:18 +00:00
4 changed files with 339 additions and 21 deletions
Showing only changes of commit f6cdd7650b - Show all commits

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

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

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