Files
FRASA-CAS/api-gateway/src/controllers/liga.controller.test.ts
Consult AS Technology 59944668a8 feat(alta): borrador, pre-llenado desde cotización y liga remota de firma
- Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar
- Pre-llenado con ?cotizacion=<id> (cliente, contacto, proyecto, montos);
  botón 'Iniciar alta' en Mis cotizaciones del cotizador
- Liga remota pública /client/firma/<token> (JWT scope firma-remota, 72h,
  rate limit, solo datos mínimos sin PLD): firma digital + subida de
  documentos que prende checkboxes en Monday Apartado
- Tokens de liga no sirven como sesión y viceversa
- Tests: api 228, alta 82, cotizador 82
2026-08-28 07:29:22 +00:00

250 lines
8.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import jwt from 'jsonwebtoken'
import {
firmarRemoto,
generarLiga,
generarLigaToken,
obtenerClientePublico,
subirDocumentosPublico,
verificarLigaToken,
} from './liga.controller.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findUnique: vi.fn(),
update: vi.fn(),
},
documento: {
findMany: vi.fn(),
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
cambiarValorColumna: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts
function reqFake(overrides: Partial<Request> = {}): Request {
return { params: {}, body: {}, ip: '127.0.0.1', ...overrides } as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const clienteFake = (overrides: Record<string, unknown> = {}) => ({
id: 'c-1',
nombreCompleto: 'CLIENTE DEMO',
curp: 'CURPSECRETA123',
rfc: 'RFCSECRETO1',
email: 'demo@example.com',
celular: '5551234567',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
payloadJson: '{"datosGenerales":{"direccion":"CALLE SECRETA 1"}}',
firmaPng: null,
mondayContactoId: null,
mondayApartadoId: null,
createdAt: new Date(),
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('token de liga (scope firma-remota)', () => {
it('ida y vuelta: scope, clienteId y expiración de 72h por defecto', () => {
const token = generarLigaToken('c-1')
const payload = verificarLigaToken(token)
expect(payload.scope).toBe('firma-remota')
expect(payload.clienteId).toBe('c-1')
const { exp, iat } = jwt.decode(token) as { exp: number; iat: number }
expect(exp - iat).toBe(72 * 3600)
})
it('rechaza un token de login (sin scope firma-remota)', () => {
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
expect(() => verificarLigaToken(tokenLogin)).toThrow('Liga inválida o expirada')
})
it('rechaza un token expirado', () => {
const expirado = jwt.sign({ scope: 'firma-remota', clienteId: 'c-1' }, SECRET, { expiresIn: '-1s' })
expect(() => verificarLigaToken(expirado)).toThrow('Liga inválida o expirada')
})
it('rechaza basura y tokens de liga sin clienteId', () => {
expect(() => verificarLigaToken('no-es-un-jwt')).toThrow('Liga inválida o expirada')
const sinCliente = jwt.sign({ scope: 'firma-remota' }, SECRET)
expect(() => verificarLigaToken(sinCliente)).toThrow('Liga inválida o expirada')
})
})
describe('generarLiga (POST /clientes/:id/liga)', () => {
it('devuelve token, url pública y expiración', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const { res, status, json } = resFake()
await generarLiga(reqFake({ params: { id: 'c-1' } }), res)
expect(status).toHaveBeenCalledWith(201)
const cuerpo = json.mock.calls[0]![0]
expect(cuerpo.url).toBe(`/client/firma/${cuerpo.token}`)
expect(verificarLigaToken(cuerpo.token).clienteId).toBe('c-1')
expect(new Date(cuerpo.expiraEn).getTime()).toBeGreaterThan(Date.now())
})
it('404 si el cliente no existe', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(generarLiga(reqFake({ params: { id: 'x' } }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
})
describe('obtenerClientePublico (GET /clientes/publico/:token)', () => {
it('expone SOLO nombre, proyecto, unidad y estado de firma/documentos (nada de PLD)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.findMany.mockResolvedValue([
{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date() },
])
const { res, json } = resFake()
const token = generarLigaToken('c-1')
await obtenerClientePublico(reqFake({ params: { token } }), res)
const cuerpo = json.mock.calls[0]![0]
expect(cuerpo).toEqual({
nombre: 'CLIENTE DEMO',
proyecto: 'DIAGONAL',
unidad: 'U-101',
firma: false,
documentos: [{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: expect.any(Date) }],
})
// Guardas explícitas anti-filtrado PLD
const crudo = JSON.stringify(cuerpo)
expect(crudo).not.toContain('CURPSECRETA123')
expect(crudo).not.toContain('RFCSECRETO1')
expect(crudo).not.toContain('CALLE SECRETA')
expect(cuerpo).not.toHaveProperty('payloadJson')
expect(cuerpo).not.toHaveProperty('curp')
expect(cuerpo).not.toHaveProperty('rfc')
expect(cuerpo).not.toHaveProperty('email')
expect(cuerpo).not.toHaveProperty('celular')
})
it('no acepta un token de login aunque sea JWT válido (401)', async () => {
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
await expect(
obtenerClientePublico(reqFake({ params: { token: tokenLogin } }), resFake().res),
).rejects.toMatchObject({ status: 401 })
expect(prismaMock.cliente.findUnique).not.toHaveBeenCalled()
})
})
describe('firmarRemoto (POST /clientes/publico/:token/firma)', () => {
const firmaPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA='
it('guarda la firma en Cliente.firmaPng', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.cliente.update.mockResolvedValue(clienteFake({ firmaPng }))
const { res, json } = resFake()
const token = generarLigaToken('c-1')
await firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: firmaPng } }), res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'c-1' },
data: { firmaPng },
})
expect(json).toHaveBeenCalledWith({ ok: true, firma: true })
})
it('rechaza una firma que no sea PNG data URL', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const token = generarLigaToken('c-1')
await expect(
firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: 'data:text/html;base64,PHNjcmlwdA==' } }), resFake().res),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
})
describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () => {
const archivoFake = {
originalname: 'curp.pdf',
path: '/tmp/uploads/c-1/CURP_abc.pdf',
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
it('rechaza tipos fuera de la whitelist (400)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const token = generarLigaToken('c-1')
await expect(
subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'CONTRATO' }, files: [archivoFake] }),
resFake().res,
),
).rejects.toMatchObject({ status: 400 })
expect(prismaMock.documento.create).not.toHaveBeenCalled()
})
it('acepta un tipo de la whitelist y persiste el documento (201)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const { res, status } = resFake()
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'curp' }, files: [archivoFake] }),
res,
)
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'c-1', tipo: 'CURP', mime: 'application/pdf' }),
})
expect(status).toHaveBeenCalledWith(201)
})
it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' }))
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
mondayMock.cambiarValorColumna.mockResolvedValue(undefined)
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'CURP' }, files: [archivoFake] }),
resFake().res,
)
expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true)
})
it('no llama a Monday sin mondayApartadoId', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'INE' }, files: [archivoFake] }),
resFake().res,
)
expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled()
})
})