UDT-006: Middleware de Autorización (RBAC enforcement) #10
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}</>
|
||||
}
|
||||
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))
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user