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,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import jwt from 'jsonwebtoken'
import { authMiddleware } from './auth.middleware.js'
// Auth forzada a activa para este archivo (en vitest el default global es false)
vi.mock('../config/index.js', () => ({
config: {
auth: {
enabled: true,
jwtSecret: 'test-jwt-secret',
jwtExpiresIn: '7d',
adminPassword: 'Frasa2026',
},
},
}))
const SECRET = 'test-jwt-secret'
function reqFake(overrides: Partial<Request> = {}): Request {
return { method: 'GET', path: '/unidades', headers: {}, ...overrides } as Request
}
function resFake(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { status } as unknown as Response, status, json }
}
const next: NextFunction = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('authMiddleware (AUTH_ENABLED=true)', () => {
it('401 sin header Authorization', () => {
const { res, status } = resFake()
authMiddleware(reqFake(), res, next)
expect(status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('401 con token inválido', () => {
const { res, status } = resFake()
authMiddleware(reqFake({ headers: { authorization: 'Bearer basura' } }), res, next)
expect(status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('pasa con un Bearer token válido y deja el payload en req', () => {
const token = jwt.sign({ sub: 'u-1', username: 'admin', rol: 'admin' }, SECRET)
const req = reqFake({ headers: { authorization: `Bearer ${token}` } })
const { res } = resFake()
authMiddleware(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(req.usuarioToken?.username).toBe('admin')
})
it('deja pasar las rutas públicas sin token (health, login, webhook monday)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/health' },
{ method: 'POST', path: '/auth/login' },
{ method: 'POST', path: '/webhooks/monday' },
]
for (const c of casos) {
vi.clearAllMocks()
authMiddleware(reqFake(c), resFake().res, next)
expect(next).toHaveBeenCalledOnce()
}
})
it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => {
const { res, status } = resFake()
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)
expect(status).toHaveBeenCalledWith(401)
})
})

View File

@@ -0,0 +1,50 @@
import type { NextFunction, Request, Response } from 'express'
import { config } from '../config/index.js'
import { verificarToken, type TokenPayload } from '../services/auth.service.js'
// Rutas públicas: no requieren token aunque AUTH_ENABLED=true. Se comparan
// contra req.path, que aquí ya está relativo al mount /api.
// Monday no puede mandar JWT, así que su webhook queda abierto (se valida por
// firma/challenge dentro del controlador).
const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
{ metodo: 'GET', ruta: '/health' },
{ metodo: 'POST', ruta: '/auth/login' },
{ metodo: 'POST', ruta: '/webhooks/monday' },
]
declare module 'express-serve-static-core' {
interface Request {
usuarioToken?: TokenPayload
}
}
/**
* Exige Bearer JWT en todo /api salvo las rutas públicas.
* Con AUTH_ENABLED=false (tests) deja pasar todo.
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
if (!config.auth.enabled) {
next()
return
}
const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta)
if (publica) {
next()
return
}
const header = req.headers.authorization
const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null
if (!token) {
res.status(401).json({ error: 'Token requerido' })
return
}
try {
req.usuarioToken = verificarToken(token)
next()
} catch {
res.status(401).json({ error: 'Token inválido o expirado' })
}
}