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
7 changed files with 161 additions and 10 deletions
Showing only changes of commit 2efd5e2fdb - Show all commits

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

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

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

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

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