UDT-006: Middleware de Autorización (RBAC enforcement) #10
@@ -9,6 +9,7 @@ export interface LoginResponseDto {
|
|||||||
username: string
|
username: string
|
||||||
nombre: string
|
nombre: string
|
||||||
rol: string
|
rol: string
|
||||||
|
permisos: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mockLoginResponse = {
|
|||||||
username: 'admin',
|
username: 'admin',
|
||||||
nombre: 'Admin',
|
nombre: 'Admin',
|
||||||
rol: 'admin',
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
src/web/src/tests/features/auth/useLogin.test.ts
Normal file
96
src/web/src/tests/features/auth/useLogin.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import React from 'react'
|
||||||
|
import { useLogin } from '../../../features/auth/hooks/useLogin'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockLoginResponseWithPermisos = {
|
||||||
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
|
||||||
|
refreshToken: 'refresh-token-abc',
|
||||||
|
expiresIn: 3600,
|
||||||
|
usuario: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin Sistema',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLoginResponseEmptyPermisos = {
|
||||||
|
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
|
||||||
|
refreshToken: 'refresh-token-abc',
|
||||||
|
expiresIn: 3600,
|
||||||
|
usuario: {
|
||||||
|
id: 2,
|
||||||
|
username: 'cajero',
|
||||||
|
nombre: 'Cajero Test',
|
||||||
|
rol: 'cajero',
|
||||||
|
permisos: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLogin — permisos propagation', () => {
|
||||||
|
it('F-login-01: response con permisos → store.user.permisos poblado', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/auth/login`, () =>
|
||||||
|
HttpResponse.json(mockLoginResponseWithPermisos, { status: 200 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({ username: 'admin', password: 'password' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.permisos).toContain('administracion:usuarios:gestionar')
|
||||||
|
expect(state.user?.permisos).toContain('administracion:roles:gestionar')
|
||||||
|
expect(state.user?.permisos).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F-login-02: response con permisos vacíos → store.user.permisos = []', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/auth/login`, () =>
|
||||||
|
HttpResponse.json(mockLoginResponseEmptyPermisos, { status: 200 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate({ username: 'cajero', password: 'password' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const state = useAuthStore.getState()
|
||||||
|
expect(state.user?.permisos).toEqual([])
|
||||||
|
expect(state.user?.permisos).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user