import { randomUUID } from 'node:crypto'; import { IncomingMessage, Server, ServerResponse, createServer } from 'node:http'; import { AddressInfo } from 'node:net'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { EncryptionService } from '../src/common/encryption.service'; import { PrismaService } from '../src/config/prisma.service'; import { hashPassword } from '../src/modules/auth/password.util'; // Nome com prefixo "mock" para escapar da restrição do Jest contra // variáveis fora de escopo dentro da factory de `jest.mock`. const mockSendMail = jest.fn().mockResolvedValue({ messageId: 'fake-message-id' }); jest.mock('nodemailer', () => ({ createTransport: jest.fn(() => ({ sendMail: mockSendMail })), })); interface TokenPair { access_token: string; refresh_token: string; } /** * Substituto local da Evolution API real (indisponível neste ambiente de * teste/CI — sem internet nem instância dedicada). Implementa o subset da * API v2 usado por `EvolutionApiClient`, o suficiente para exercitar o * fluxo ponta a ponta desta etapa contra um servidor HTTP real. */ class FakeEvolutionServer { server: Server; baseUrl = ''; connectionState: 'open' | 'connecting' | 'close' = 'close'; requests: Array<{ method: string; url: string; apikey?: string; body: unknown }> = []; async start(): Promise { this.server = createServer((req, res) => this.handle(req, res)); await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)); const { port } = this.server.address() as AddressInfo; this.baseUrl = `http://127.0.0.1:${port}`; } async stop(): Promise { await new Promise((resolve) => this.server.close(() => resolve())); } private handle(req: IncomingMessage, res: ServerResponse): void { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); const body: unknown = raw ? JSON.parse(raw) : undefined; const url = req.url ?? ''; const method = req.method ?? 'GET'; this.requests.push({ method, url, apikey: req.headers.apikey as string | undefined, body }); const json = (status: number, payload: unknown) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(payload)); }; if (method === 'GET' && url === '/') { return json(200, { version: '2.3.0', message: 'Welcome to the Evolution API' }); } if (method === 'POST' && url === '/instance/create') { return json(201, { instance: body }); } if (method === 'GET' && url.startsWith('/instance/connect/')) { return json(200, { base64: 'data:image/png;base64,ZmFrZQ==', pairingCode: '12-34' }); } if (method === 'GET' && url.startsWith('/instance/connectionState/')) { return json(200, { instance: { state: this.connectionState } }); } if (method === 'DELETE' && url.startsWith('/instance/logout/')) { return json(200, {}); } if (method === 'DELETE' && url.startsWith('/instance/delete/')) { return json(200, {}); } if (method === 'POST' && url.startsWith('/message/sendText/')) { const numero = (body as { number?: string } | undefined)?.number; if (numero === '0000000000') { return json(500, { message: 'falha simulada de envio' }); } return json(201, { status: 'PENDING' }); } json(404, { message: 'rota não simulada' }); }); } } describe('Integrações — Evolution API (WhatsApp) e E-mail (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let encryptionService: EncryptionService; let httpServer: Parameters[0]; let evolution: FakeEvolutionServer; const senha = 'SenhaForteDemo#26'; const suffix = randomUUID().slice(0, 8); const masterEmail = `master-etapa4-${suffix}@teste.com`; let masterId: string; let masterToken: string; const tenantIds: string[] = []; const planoIds: string[] = []; const tenantUserIds: string[] = []; const evolutionConfigIds: string[] = []; const masterEmailConfigIds: string[] = []; async function authMaster(): Promise { const res = await request(httpServer) .post('/auth/master/login') .send({ email: masterEmail, senha }) .expect(200); return (res.body as TokenPair).access_token; } function asMaster() { return { get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${masterToken}`), post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`), put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${masterToken}`), delete: (url: string) => request(httpServer).delete(url).set('Authorization', `Bearer ${masterToken}`), }; } function asTenant(token: string) { return { get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`), post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`), put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`), delete: (url: string) => request(httpServer).delete(url).set('Authorization', `Bearer ${token}`), }; } async function criarPlano(overrides: Partial> = {}) { const res = await asMaster() .post('/master/planos') .send({ nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`, valor_mensal: 199.9, limite_instancias_whatsapp: 1, ...overrides, }) .expect(201); planoIds.push((res.body as { id: string }).id); return res.body as { id: string }; } async function criarTenantComAdmin(planoId: string) { const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`; const res = await asMaster() .post('/master/tenants') .send({ razao_social: `Tenant Integração ${suffix} ${randomUUID().slice(0, 6)}`, email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`, plano_id: planoId, admin: { nome: 'Admin de Teste', email: emailAdmin }, }) .expect(201); const body = res.body as { tenant: { id: string }; admin: { id: string; email: string; senha_provisoria: string }; }; tenantIds.push(body.tenant.id); tenantUserIds.push(body.admin.id); await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } }); const login = await request(httpServer) .post('/auth/tenant/login') .send({ email: body.admin.email, senha: body.admin.senha_provisoria }) .expect(200); return { tenant: body.tenant, admin: body.admin, token: (login.body as TokenPair).access_token, }; } async function criarEvolutionConfigAtiva() { const res = await asMaster() .post('/master/evolution-config') .send({ nome: `Evolution ${suffix}`, base_url: evolution.baseUrl, api_key: 'chave-evolution-secreta-1234', }) .expect(201); const body = res.body as { id: string }; evolutionConfigIds.push(body.id); return body; } beforeAll(async () => { evolution = new FakeEvolutionServer(); await evolution.start(); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), ); await app.init(); httpServer = app.getHttpServer() as Parameters[0]; prisma = app.get(PrismaService); encryptionService = app.get(EncryptionService); const senha_hash = await hashPassword(senha); const master = await prisma.master_admins.create({ data: { nome: 'Master Etapa 4', email: masterEmail, senha_hash }, }); masterId = master.id; masterToken = await authMaster(); }); afterAll(async () => { await prisma.tenant_whatsapp_instances.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.tenant_email_config.deleteMany({ where: { tenant_id: { in: tenantIds } } }); await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } }); await prisma.planos.deleteMany({ where: { id: { in: planoIds } } }); await prisma.evolution_api_config.deleteMany({ where: { id: { in: evolutionConfigIds } } }); await prisma.master_email_config.deleteMany({ where: { id: { in: masterEmailConfigIds } } }); await prisma.auth_refresh_tokens.deleteMany({ where: { usuario_id: { in: [masterId, ...tenantUserIds] } }, }); await prisma.login_attempts.deleteMany({ where: { usuario_tipo: 'master', email: masterEmail }, }); await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } }); await prisma.master_admins.deleteMany({ where: { id: masterId } }); await app.close(); await evolution.stop(); }); describe('Evolution API — config master', () => { it('cadastra config, nunca retorna api_key em texto puro e testa conexão', async () => { const config = await criarEvolutionConfigAtiva(); const body = config as unknown as Record; expect(body.api_key_cripto).toBeUndefined(); expect(body.api_key).toMatch(/^\*+1234$/); const raw = await prisma.evolution_api_config.findUniqueOrThrow({ where: { id: config.id } }); expect(raw.api_key_cripto).not.toBe('chave-evolution-secreta-1234'); expect(encryptionService.decrypt(raw.api_key_cripto)).toBe('chave-evolution-secreta-1234'); const lista = await asMaster().get('/master/evolution-config').expect(200); expect( (lista.body as Array>).every((c) => c.api_key_cripto === undefined), ).toBe(true); const teste = await asMaster() .post(`/master/evolution-config/${config.id}/testar-conexao`) .expect(201); expect((teste.body as { status_http: number; versao: string }).status_http).toBe(200); expect((teste.body as { versao: string }).versao).toBe('2.3.0'); }); it('bloqueia endpoints de evolution-config com token de tenant_user', async () => { const plano = await criarPlano(); const { token } = await criarTenantComAdmin(plano.id); await asTenant(token).get('/master/evolution-config').expect(403); }); }); describe('WhatsApp — instâncias do tenant', () => { it('cria instância, obtém QR code e reflete status conectado após consultar', async () => { await criarEvolutionConfigAtiva(); const plano = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token } = await criarTenantComAdmin(plano.id); const criada = await asTenant(token).post('/tenant/whatsapp/instancias').send({}).expect(201); const instancia = criada.body as { id: string; status: string; instance_name: string }; expect(instancia.status).toBe('desconectado'); expect(instancia.instance_name).toMatch(/^tenant-/); const qrcode = await asTenant(token) .get(`/tenant/whatsapp/instancias/${instancia.id}/qrcode`) .expect(200); expect((qrcode.body as { base64: string }).base64).toContain('base64'); evolution.connectionState = 'open'; const status = await asTenant(token) .get(`/tenant/whatsapp/instancias/${instancia.id}/status`) .expect(200); expect((status.body as { status: string }).status).toBe('conectado'); }); it('criar instância além do limite do plano retorna erro claro', async () => { await criarEvolutionConfigAtiva(); const plano = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token } = await criarTenantComAdmin(plano.id); await asTenant(token).post('/tenant/whatsapp/instancias').send({}).expect(201); const segunda = await asTenant(token) .post('/tenant/whatsapp/instancias') .send({}) .expect(400); expect((segunda.body as { message: string }).message).toMatch(/limite/i); }); it('teste de envio funciona (sucesso) e reporta falha claramente quando a Evolution API recusa', async () => { await criarEvolutionConfigAtiva(); const plano = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token } = await criarTenantComAdmin(plano.id); const criada = await asTenant(token).post('/tenant/whatsapp/instancias').send({}).expect(201); const instanceId = (criada.body as { id: string }).id; const sucesso = await asTenant(token) .post(`/tenant/whatsapp/instancias/${instanceId}/testar-envio`) .send({ numero: '5511999998888' }) .expect(201); expect(sucesso.body).toMatchObject({ sucesso: true }); const falha = await asTenant(token) .post(`/tenant/whatsapp/instancias/${instanceId}/testar-envio`) .send({ numero: '0000000000' }) .expect(201); expect((falha.body as { sucesso: boolean; erro: string }).sucesso).toBe(false); expect((falha.body as { erro: string }).erro).toEqual(expect.any(String)); }); it('desconecta e remove a instância', async () => { await criarEvolutionConfigAtiva(); const plano = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token } = await criarTenantComAdmin(plano.id); const criada = await asTenant(token).post('/tenant/whatsapp/instancias').send({}).expect(201); const instanceId = (criada.body as { id: string }).id; await asTenant(token).delete(`/tenant/whatsapp/instancias/${instanceId}`).expect(204); await asTenant(token).get(`/tenant/whatsapp/instancias/${instanceId}/status`).expect(404); }); it('bloqueia acesso a instância de outro tenant (RLS) com 404', async () => { await criarEvolutionConfigAtiva(); const planoA = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token: tokenA } = await criarTenantComAdmin(planoA.id); const planoB = await criarPlano({ limite_instancias_whatsapp: 1 }); const { token: tokenB } = await criarTenantComAdmin(planoB.id); const criada = await asTenant(tokenA) .post('/tenant/whatsapp/instancias') .send({}) .expect(201); const instanceId = (criada.body as { id: string }).id; await asTenant(tokenB).get(`/tenant/whatsapp/instancias/${instanceId}/status`).expect(404); }); }); describe('E-mail — config master e tenant', () => { async function criarMasterEmailConfig(overrides: Partial> = {}) { const res = await asMaster() .post('/master/email-config') .send({ smtp_host: 'smtp.padrao.teste', smtp_porta: 587, smtp_usuario: 'padrao@teste.com', smtp_senha: 'senha-smtp-padrao-9876', email_remetente: 'padrao@seconectai.teste', ...overrides, }) .expect(201); const body = res.body as { id: string }; masterEmailConfigIds.push(body.id); return body; } it('cadastra config padrão da plataforma sem nunca retornar a senha em texto puro', async () => { const config = await criarMasterEmailConfig(); const body = config as unknown as Record; expect(body.smtp_senha_cripto).toBeUndefined(); expect(body.smtp_senha).toMatch(/^\*+9876$/); const raw = await prisma.master_email_config.findUniqueOrThrow({ where: { id: config.id } }); expect(encryptionService.decrypt(raw.smtp_senha_cripto)).toBe('senha-smtp-padrao-9876'); }); it('tenant usa o padrão por default e consegue trocar para SMTP próprio (PUT exige campos)', async () => { await criarMasterEmailConfig(); const plano = await criarPlano(); const { token } = await criarTenantComAdmin(plano.id); const inicial = await asTenant(token).get('/tenant/email-config').expect(200); expect(inicial.body).toEqual({ usar_config_padrao: true }); await asTenant(token) .put('/tenant/email-config') .send({ usar_config_padrao: false }) .expect(400); const proprio = await asTenant(token) .put('/tenant/email-config') .send({ usar_config_padrao: false, smtp_host: 'smtp.proprio.teste', smtp_porta: 465, smtp_usuario: 'tenant@proprio.teste', smtp_senha: 'senha-smtp-propria-4321', email_remetente: 'contato@tenantproprio.teste', }) .expect(200); expect(proprio.body).toMatchObject({ usar_config_padrao: false, smtp_host: 'smtp.proprio.teste', email_remetente: 'contato@tenantproprio.teste', }); expect((proprio.body as { smtp_senha: string }).smtp_senha).toMatch(/^\*+4321$/); const voltaPadrao = await asTenant(token) .put('/tenant/email-config') .send({ usar_config_padrao: true }) .expect(200); expect(voltaPadrao.body).toEqual({ usar_config_padrao: true }); }); it('teste de envio funciona usando o padrão e usando o SMTP próprio do tenant', async () => { await criarMasterEmailConfig({ email_remetente: `padrao-${suffix}@seconectai.teste` }); const plano = await criarPlano(); const { token } = await criarTenantComAdmin(plano.id); mockSendMail.mockClear(); await asTenant(token) .post('/tenant/email-config/testar-envio') .send({ destinatario: 'destino@teste.com' }) .expect(201) .expect((res) => { if (!(res.body as { sucesso: boolean }).sucesso) { throw new Error(`envio via padrão falhou: ${JSON.stringify(res.body)}`); } }); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ from: `padrao-${suffix}@seconectai.teste` }), ); await asTenant(token) .put('/tenant/email-config') .send({ usar_config_padrao: false, smtp_host: 'smtp.proprio.teste', smtp_porta: 465, smtp_usuario: 'tenant@proprio.teste', smtp_senha: 'senha-smtp-propria-4321', email_remetente: `proprio-${suffix}@tenantproprio.teste`, }) .expect(200); mockSendMail.mockClear(); await asTenant(token) .post('/tenant/email-config/testar-envio') .send({ destinatario: 'destino@teste.com' }) .expect(201); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ from: `proprio-${suffix}@tenantproprio.teste` }), ); }); it('bloqueia endpoints de email-config do tenant com token de master e vice-versa', async () => { const plano = await criarPlano(); const { token } = await criarTenantComAdmin(plano.id); await asTenant(token).get('/master/email-config').expect(403); await asMaster().get('/tenant/email-config').expect(403); }); }); });