feat(auth): login JWT en API y las 4 apps
- Modelo Usuario + seed desde lista de vendedores (18 usuarios, bcrypt cost 12) - POST /api/auth/login + /api/auth/me; middleware Bearer en todo /api excepto health, login y webhook Monday; flag AUTH_ENABLED - Username = nombre completo normalizado (case/acentos insensible), password inicial Frasa2026, admin vía ADMIN_PASSWORD - Apps: LoginScreen + AuthGate (StudioBlank), token en localStorage, header Authorization en todas las llamadas, logout en 401 - Tests: api 136, cotizador 71, alta 65, cobranza 71, agente-ia 13, landing 4
This commit is contained in:
@@ -35,7 +35,7 @@ describe('getUnidades', () => {
|
||||
|
||||
const resultado = await getUnidades()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades')
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades', {})
|
||||
expect(resultado.usandoSeed).toBe(false)
|
||||
expect(resultado.unidades).toEqual([unidadApi])
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Unidad } from '../types'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
import { conAuth, manejar401 } from './auth'
|
||||
|
||||
// TODO produccion: '/api' same-origin detras de NGINX
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||
@@ -17,7 +18,8 @@ export interface GetUnidadesResult {
|
||||
*/
|
||||
export async function getUnidades(): Promise<GetUnidadesResult> {
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/unidades`)
|
||||
const resp = await fetch(`${API_URL}/unidades`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const unidades = (await resp.json()) as Unidad[]
|
||||
return { unidades, usandoSeed: false }
|
||||
|
||||
124
apps/cotizador/src/services/auth.test.ts
Normal file
124
apps/cotizador/src/services/auth.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ErrorLogin,
|
||||
conAuth,
|
||||
getSesion,
|
||||
getToken,
|
||||
login,
|
||||
logout,
|
||||
manejar401,
|
||||
} from './auth'
|
||||
|
||||
/** Stub sencillo de localStorage para correr en entorno node */
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return mapa.size
|
||||
},
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const sesionOk = {
|
||||
token: 'jwt-de-prueba',
|
||||
usuario: { nombre: 'ERICK ISRAEL PEÑA RAMOS', rol: 'vendedor', asignacion: 'MONTEBELLO' },
|
||||
}
|
||||
|
||||
function respuestaLoginOk(): Response {
|
||||
return new Response(JSON.stringify(sesionOk), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('almacenamiento de sesión', () => {
|
||||
it('sin sesión: getSesion/getToken devuelven null', () => {
|
||||
expect(getSesion()).toBeNull()
|
||||
expect(getToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('JSON corrupto en storage se trata como "sin sesión"', () => {
|
||||
localStorage.setItem('frasa_auth', '{basura')
|
||||
expect(getSesion()).toBeNull()
|
||||
})
|
||||
|
||||
it('logout limpia la sesión', () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||
expect(getToken()).toBe('jwt-de-prueba')
|
||||
logout()
|
||||
expect(getSesion()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('guarda token + usuario y devuelve el usuario', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(respuestaLoginOk()))
|
||||
|
||||
const usuario = await login('erick israel pena ramos', 'Frasa2026')
|
||||
|
||||
expect(usuario.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
|
||||
expect(getToken()).toBe('jwt-de-prueba')
|
||||
expect(getSesion()?.usuario.rol).toBe('vendedor')
|
||||
})
|
||||
|
||||
it('credenciales malas: ErrorLogin genérico y no guarda nada', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('{"error":"Credenciales inválidas"}', { status: 401 })),
|
||||
)
|
||||
|
||||
await expect(login('admin', 'mala')).rejects.toThrow('Usuario o contraseña incorrectos')
|
||||
await expect(login('admin', 'mala')).rejects.toBeInstanceOf(ErrorLogin)
|
||||
expect(getSesion()).toBeNull()
|
||||
})
|
||||
|
||||
it('sin red: ErrorLogin de conexión', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
|
||||
await expect(login('admin', 'x')).rejects.toThrow('No se pudo conectar con el servidor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('conAuth', () => {
|
||||
it('sin token devuelve el init tal cual', () => {
|
||||
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } }
|
||||
expect(conAuth(init)).toBe(init)
|
||||
expect(conAuth()).toEqual({})
|
||||
})
|
||||
|
||||
it('con token agrega Authorization: Bearer conservando headers', () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||
const resultado = conAuth({ headers: { 'Content-Type': 'application/json' } })
|
||||
expect(resultado.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer jwt-de-prueba',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('manejar401', () => {
|
||||
it('con 401 limpia la sesión', () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||
manejar401(new Response('', { status: 401 }))
|
||||
expect(getSesion()).toBeNull()
|
||||
})
|
||||
|
||||
it('con otro status no toca la sesión', () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||
manejar401(new Response('', { status: 500 }))
|
||||
expect(getSesion()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
77
apps/cotizador/src/services/auth.ts
Normal file
77
apps/cotizador/src/services/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||
const STORAGE_KEY = 'frasa_auth'
|
||||
|
||||
export interface UsuarioSesion {
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
}
|
||||
|
||||
export interface Sesion {
|
||||
token: string
|
||||
usuario: UsuarioSesion
|
||||
}
|
||||
|
||||
/** Error de login con mensaje genérico (no filtra si el usuario existe) */
|
||||
export class ErrorLogin extends Error {
|
||||
constructor(message = 'Usuario o contraseña incorrectos') {
|
||||
super(message)
|
||||
this.name = 'ErrorLogin'
|
||||
}
|
||||
}
|
||||
|
||||
export function getSesion(): Sesion | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const sesion = JSON.parse(raw) as Sesion
|
||||
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
|
||||
return sesion
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return getSesion()?.token ?? null
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
/** POST /auth/login; guarda token + usuario en localStorage */
|
||||
export async function login(username: string, password: string): Promise<UsuarioSesion> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
} catch {
|
||||
throw new ErrorLogin('No se pudo conectar con el servidor')
|
||||
}
|
||||
if (!res.ok) throw new ErrorLogin()
|
||||
const data = (await res.json()) as Sesion
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
|
||||
return data.usuario
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
|
||||
* Sin token devuelve el init tal cual (las llamadas siguen iguales que antes).
|
||||
*/
|
||||
export function conAuth(init: RequestInit = {}): RequestInit {
|
||||
const token = getToken()
|
||||
if (!token) return init
|
||||
return { ...init, headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
/** Si el API responde 401: cierra sesión y recarga para volver al login */
|
||||
export function manejar401(res: Response): void {
|
||||
if (res.status !== 401) return
|
||||
logout()
|
||||
if (typeof window !== 'undefined') window.location.reload()
|
||||
}
|
||||
Reference in New Issue
Block a user