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:
2026-08-27 08:20:25 +00:00
parent b39fe6334f
commit de11af985e
40 changed files with 2085 additions and 38 deletions

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
import type { Usuario } from '@prisma/client'
import { login, me, normalizarUsername, verificarToken } from './auth.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
const prismaMock = vi.hoisted(() => ({
usuario: {
findUnique: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
return {
id: 'u-1',
username: 'erick israel pena ramos',
nombre: 'ERICK ISRAEL PEÑA RAMOS',
passwordHash: bcrypt.hashSync('Frasa2026', 12),
rol: 'vendedor',
asignacion: 'MONTEBELLO',
empleadoId: 13676,
activo: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('normalizarUsername', () => {
it('minúsculas, sin acentos ni ñ, espacios simples', () => {
expect(normalizarUsername(' ERICK ISRAEL PEÑA RAMOS ')).toBe('erick israel pena ramos')
expect(normalizarUsername('Ma. Cristina Barrera López')).toBe('ma. cristina barrera lopez')
})
})
describe('login', () => {
it('devuelve token y usuario público con credenciales correctas', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
const resultado = await login('erick israel pena ramos', 'Frasa2026')
expect(resultado.token).toBeTruthy()
expect(resultado.usuario).toEqual({
nombre: 'ERICK ISRAEL PEÑA RAMOS',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
})
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
})
it('es case-insensitive y tolerante a espacios extra y acentos', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
const resultado = await login(' ERICK Israel PEÑA ramos ', 'Frasa2026')
expect(resultado.token).toBeTruthy()
expect(prismaMock.usuario.findUnique).toHaveBeenCalledWith({
where: { username: 'erick israel pena ramos' },
})
})
it('rechaza password incorrecto con error genérico', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
await expect(login('erick israel pena ramos', 'otra')).rejects.toThrow('Credenciales inválidas')
})
it('rechaza usuario inexistente con el mismo error genérico', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
await expect(login('nadie', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
})
it('rechaza usuario inactivo con el mismo error genérico', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
await expect(login('erick israel pena ramos', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
})
})
describe('verificarToken', () => {
it('lanza 401 con token basura', () => {
expect(() => verificarToken('no-es-un-jwt')).toThrow('Token inválido o expirado')
})
})
describe('me', () => {
it('devuelve el usuario público del token', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
const resultado = await me({ sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' })
expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
})
it('rechaza si el usuario ya no existe o está inactivo', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
})
})

View File

@@ -0,0 +1,80 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import type { Usuario } from '@prisma/client'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
export interface UsuarioPublico {
nombre: string
rol: string
asignacion: string | null
}
export interface LoginResult {
token: string
usuario: UsuarioPublico
}
/**
* Normaliza un username: minúsculas, sin acentos, espacios simples.
* El login es case-insensitive y tolerante a espacios extra.
*/
export function normalizarUsername(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
}
export function aPublico(usuario: Usuario): UsuarioPublico {
return { nombre: usuario.nombre, rol: usuario.rol, asignacion: usuario.asignacion }
}
/**
* Verifica credenciales y emite un JWT. Errores genéricos ("Credenciales
* inválidas") para no filtrar si el usuario existe o está inactivo.
*/
export async function login(username: string, password: string): Promise<LoginResult> {
const invalidas = () => new AppError(401, 'Credenciales inválidas')
const usuario = await prisma.usuario.findUnique({
where: { username: normalizarUsername(username) },
})
if (!usuario || !usuario.activo) throw invalidas()
const ok = await bcrypt.compare(password, usuario.passwordHash)
if (!ok) throw invalidas()
const token = jwt.sign(
{ sub: usuario.id, username: usuario.username, rol: usuario.rol },
config.auth.jwtSecret,
{ expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions,
)
return { token, usuario: aPublico(usuario) }
}
/** Payload del JWT emitido por login() */
export interface TokenPayload {
sub: string
username: string
rol: string
}
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
export function verificarToken(token: string): TokenPayload {
try {
return jwt.verify(token, config.auth.jwtSecret) as TokenPayload
} catch {
throw new AppError(401, 'Token inválido o expirado')
}
}
/** Devuelve el usuario público de un token válido (GET /auth/me) */
export async function me(payload: TokenPayload): Promise<UsuarioPublico> {
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
return aPublico(usuario)
}