From 2efd5e2fdbadc9e20c2554fb8503880762e7f144 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:39:18 -0300 Subject: [PATCH] feat(web): authStore + useLogin persisten permisos [UDT-006] --- src/web/src/features/auth/api/authApi.ts | 1 + src/web/src/features/auth/hooks/useLogin.ts | 1 + src/web/src/stores/authStore.ts | 1 + .../tests/features/auth/LoginPage.test.tsx | 2 +- .../src/tests/features/auth/authApi.test.ts | 1 + .../src/tests/features/auth/useLogin.test.ts | 96 +++++++++++++++++++ src/web/src/tests/stores/authStore.test.ts | 69 +++++++++++-- 7 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 src/web/src/tests/features/auth/useLogin.test.ts diff --git a/src/web/src/features/auth/api/authApi.ts b/src/web/src/features/auth/api/authApi.ts index 0b7fe43..0955bfc 100644 --- a/src/web/src/features/auth/api/authApi.ts +++ b/src/web/src/features/auth/api/authApi.ts @@ -9,6 +9,7 @@ export interface LoginResponseDto { username: string nombre: string rol: string + permisos: string[] } } diff --git a/src/web/src/features/auth/hooks/useLogin.ts b/src/web/src/features/auth/hooks/useLogin.ts index a637832..b4d1f1b 100644 --- a/src/web/src/features/auth/hooks/useLogin.ts +++ b/src/web/src/features/auth/hooks/useLogin.ts @@ -19,6 +19,7 @@ export function useLogin() { username: data.usuario.username, nombre: data.usuario.nombre, rol: data.usuario.rol, + permisos: data.usuario.permisos ?? [], }, accessToken: data.accessToken, refreshToken: data.refreshToken, diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts index cfa8a68..7e7ebec 100644 --- a/src/web/src/stores/authStore.ts +++ b/src/web/src/stores/authStore.ts @@ -6,6 +6,7 @@ export interface AuthUser { username: string nombre: string rol: string + permisos: string[] } interface SetAuthPayload { diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx index 8be4e3d..d99c99b 100644 --- a/src/web/src/tests/features/auth/LoginPage.test.tsx +++ b/src/web/src/tests/features/auth/LoginPage.test.tsx @@ -21,7 +21,7 @@ const mockLoginResponse = { accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', refreshToken: 'refresh-token-abc', 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( diff --git a/src/web/src/tests/features/auth/authApi.test.ts b/src/web/src/tests/features/auth/authApi.test.ts index fb4780c..687e6f6 100644 --- a/src/web/src/tests/features/auth/authApi.test.ts +++ b/src/web/src/tests/features/auth/authApi.test.ts @@ -14,6 +14,7 @@ const mockLoginResponse = { username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], }, } diff --git a/src/web/src/tests/features/auth/useLogin.test.ts b/src/web/src/tests/features/auth/useLogin.test.ts new file mode 100644 index 0000000..e8ac80c --- /dev/null +++ b/src/web/src/tests/features/auth/useLogin.test.ts @@ -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() + }) +}) diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index fdc3b95..ba6b45f 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -28,7 +28,7 @@ describe('authStore', () => { describe('setAuth', () => { it('stores user and accessToken in state', () => { 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', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -43,7 +43,7 @@ describe('authStore', () => { it('persists auth data to localStorage under auth-storage key', () => { 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', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -61,7 +61,7 @@ describe('authStore', () => { it('setAuth_persistsRefreshTokenAndExpiresAt', () => { const before = Date.now() 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', refreshToken: 'opaque-refresh-xyz', expiresIn: 3600, @@ -83,12 +83,63 @@ describe('authStore', () => { expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz') 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', () => { + 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', () => { 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', refreshToken: 'refresh-token', expiresIn: 3600, @@ -106,7 +157,7 @@ describe('authStore', () => { describe('updateAccess', () => { 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({ user: originalUser, accessToken: 'old-access', @@ -130,7 +181,7 @@ describe('authStore', () => { it('logout_callsApi_thenClearsAuth', async () => { // Set up auth state with a token so logout() will try to call the API 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', refreshToken: 'refresh-token', expiresIn: 3600, @@ -150,7 +201,7 @@ describe('authStore', () => { it('logout_apiFails_stillClearsAuth', async () => { 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', refreshToken: 'refresh-token', expiresIn: 3600, @@ -175,7 +226,7 @@ describe('authStore', () => { describe('legacy logout compatibility (via clearAuth)', () => { it('clearAuth clears user and accessToken from state', () => { 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', refreshToken: 'some-refresh', expiresIn: 3600, @@ -190,7 +241,7 @@ describe('authStore', () => { it('clearAuth removes auth-storage from localStorage', () => { 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', refreshToken: 'some-refresh', expiresIn: 3600,