487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
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<void> {
|
|
this.server = createServer((req, res) => this.handle(req, res));
|
|
await new Promise<void>((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<void> {
|
|
await new Promise<void>((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<typeof request>[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<string> {
|
|
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<Record<string, unknown>> = {}) {
|
|
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<typeof request>[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<string, unknown>;
|
|
|
|
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<Record<string, unknown>>).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<Record<string, unknown>> = {}) {
|
|
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<string, unknown>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|