first commit
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/config/prisma.service';
|
||||
import { hashPassword } from '../src/modules/auth/password.util';
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
describe('Auth (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let httpServer: Parameters<typeof request>[0];
|
||||
|
||||
const senha = 'SenhaForteDemo#26';
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
|
||||
const masterEmail = `master-${suffix}@teste.com`;
|
||||
const tenantAAdminEmail = `admin-a-${suffix}@teste.com`;
|
||||
const tenantAVendedorEmail = `vendedor-a-${suffix}@teste.com`;
|
||||
const tenantBAdminEmail = `admin-b-${suffix}@teste.com`;
|
||||
|
||||
let masterId: string;
|
||||
let tenantAId: string;
|
||||
let tenantBId: string;
|
||||
let tenantAAdminId: string;
|
||||
let tenantAVendedorId: string;
|
||||
let tenantBAdminId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
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);
|
||||
|
||||
const senha_hash = await hashPassword(senha);
|
||||
|
||||
const master = await prisma.master_admins.create({
|
||||
data: { nome: 'Master Teste', email: masterEmail, senha_hash },
|
||||
});
|
||||
masterId = master.id;
|
||||
|
||||
const tenantA = await prisma.tenants.create({
|
||||
data: {
|
||||
razao_social: `Tenant A ${suffix}`,
|
||||
email_contato: `contato-a-${suffix}@teste.com`,
|
||||
status: 'ativo',
|
||||
},
|
||||
});
|
||||
tenantAId = tenantA.id;
|
||||
|
||||
const tenantB = await prisma.tenants.create({
|
||||
data: {
|
||||
razao_social: `Tenant B ${suffix}`,
|
||||
email_contato: `contato-b-${suffix}@teste.com`,
|
||||
status: 'ativo',
|
||||
},
|
||||
});
|
||||
tenantBId = tenantB.id;
|
||||
|
||||
const tenantAAdmin = await prisma.tenant_users.create({
|
||||
data: {
|
||||
tenant_id: tenantAId,
|
||||
nome: 'Admin Tenant A',
|
||||
email: tenantAAdminEmail,
|
||||
senha_hash,
|
||||
role: 'tenant_admin',
|
||||
},
|
||||
});
|
||||
tenantAAdminId = tenantAAdmin.id;
|
||||
|
||||
const tenantAVendedor = await prisma.tenant_users.create({
|
||||
data: {
|
||||
tenant_id: tenantAId,
|
||||
nome: 'Vendedor Tenant A',
|
||||
email: tenantAVendedorEmail,
|
||||
senha_hash,
|
||||
role: 'vendedor',
|
||||
},
|
||||
});
|
||||
tenantAVendedorId = tenantAVendedor.id;
|
||||
|
||||
const tenantBAdmin = await prisma.tenant_users.create({
|
||||
data: {
|
||||
tenant_id: tenantBId,
|
||||
nome: 'Admin Tenant B',
|
||||
email: tenantBAdminEmail,
|
||||
senha_hash,
|
||||
role: 'tenant_admin',
|
||||
},
|
||||
});
|
||||
tenantBAdminId = tenantBAdmin.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const usuarioIds = [masterId, tenantAAdminId, tenantAVendedorId, tenantBAdminId];
|
||||
const emails = [masterEmail, tenantAAdminEmail, tenantAVendedorEmail, tenantBAdminEmail];
|
||||
|
||||
await prisma.auth_refresh_tokens.deleteMany({ where: { usuario_id: { in: usuarioIds } } });
|
||||
await prisma.password_reset_tokens.deleteMany({ where: { usuario_id: { in: usuarioIds } } });
|
||||
await prisma.login_attempts.deleteMany({ where: { email: { in: emails } } });
|
||||
await prisma.tenant_users.deleteMany({ where: { tenant_id: { in: [tenantAId, tenantBId] } } });
|
||||
await prisma.tenants.deleteMany({ where: { id: { in: [tenantAId, tenantBId] } } });
|
||||
await prisma.master_admins.deleteMany({ where: { id: masterId } });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('login master com credenciais corretas retorna access+refresh token', async () => {
|
||||
const res = await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: masterEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const body = res.body as TokenPair;
|
||||
expect(body.access_token).toEqual(expect.any(String));
|
||||
expect(body.refresh_token).toEqual(expect.any(String));
|
||||
expect(body.token_type).toBe('Bearer');
|
||||
});
|
||||
|
||||
it('login tenant com credenciais corretas retorna access+refresh token com tenant_id/role no payload', async () => {
|
||||
const res = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const body = res.body as TokenPair;
|
||||
expect(body.access_token).toEqual(expect.any(String));
|
||||
expect(body.refresh_token).toEqual(expect.any(String));
|
||||
|
||||
const me = await request(httpServer)
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${body.access_token}`)
|
||||
.expect(200);
|
||||
|
||||
expect(me.body).toMatchObject({
|
||||
tipo: 'tenant_user',
|
||||
tenant_id: tenantAId,
|
||||
role: 'tenant_admin',
|
||||
sub: tenantAAdminId,
|
||||
});
|
||||
});
|
||||
|
||||
it('senha incorreta retorna 401', async () => {
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha: 'senha-errada-qualquer' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('e-mail inexistente retorna 401 (sem vazar se o e-mail existe)', async () => {
|
||||
await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: 'nao-existe-nenhum@teste.com', senha })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token', () => {
|
||||
it('rotaciona corretamente; token antigo é rejeitado após rotação', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAVendedorEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const originalRefreshToken = (login.body as TokenPair).refresh_token;
|
||||
|
||||
const refreshed = await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: originalRefreshToken })
|
||||
.expect(200);
|
||||
|
||||
const newTokens = refreshed.body as TokenPair;
|
||||
expect(newTokens.refresh_token).not.toBe(originalRefreshToken);
|
||||
expect(newTokens.access_token).toEqual(expect.any(String));
|
||||
|
||||
// o novo refresh token, por sua vez, ainda é válido (rotaciona de novo normalmente)
|
||||
const secondRotation = await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: newTokens.refresh_token })
|
||||
.expect(200);
|
||||
expect((secondRotation.body as TokenPair).refresh_token).not.toBe(newTokens.refresh_token);
|
||||
});
|
||||
|
||||
it('token antigo (já rotacionado) usado de novo é rejeitado — e, por segurança, revoga toda a família de tokens', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const originalRefreshToken = (login.body as TokenPair).refresh_token;
|
||||
|
||||
const refreshed = await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: originalRefreshToken })
|
||||
.expect(200);
|
||||
const rotatedRefreshToken = (refreshed.body as TokenPair).refresh_token;
|
||||
|
||||
// reusar o token antigo (já revogado pela rotação) é rejeitado
|
||||
await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: originalRefreshToken })
|
||||
.expect(401);
|
||||
|
||||
// esse reuso é tratado como indício de roubo de token: por precaução,
|
||||
// toda a família (incluindo o token rotacionado válido) é revogada
|
||||
await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: rotatedRefreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('refresh token inválido/inexistente retorna 401', async () => {
|
||||
await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: 'token-que-nao-existe' })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('revoga o refresh token — uso após logout é rejeitado', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const { refresh_token } = login.body as TokenPair;
|
||||
|
||||
await request(httpServer).post('/auth/logout').send({ refresh_token }).expect(204);
|
||||
|
||||
await request(httpServer).post('/auth/refresh').send({ refresh_token }).expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('esqueci minha senha / reset', () => {
|
||||
it('gera token de uso único, com expiração, e permite redefinir a senha', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await request(httpServer)
|
||||
.post('/auth/password/forgot')
|
||||
.send({ email: tenantAVendedorEmail, usuario_tipo: 'tenant_user' })
|
||||
.expect(200);
|
||||
|
||||
const logLine = consoleSpy.mock.calls
|
||||
.map((args) => String(args[0]))
|
||||
.find((line) => line.includes('token='));
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
expect(logLine).toBeDefined();
|
||||
const token = /token=(\S+)/.exec(logLine ?? '')?.[1];
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const novaSenha = 'NovaSenhaForte#26';
|
||||
|
||||
await request(httpServer)
|
||||
.post('/auth/password/reset')
|
||||
.send({ token, nova_senha: novaSenha })
|
||||
.expect(200);
|
||||
|
||||
// token de uso único: reutilizar deve falhar
|
||||
await request(httpServer)
|
||||
.post('/auth/password/reset')
|
||||
.send({ token, nova_senha: 'OutraSenha#26' })
|
||||
.expect(401);
|
||||
|
||||
// senha antiga não funciona mais
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAVendedorEmail, senha })
|
||||
.expect(401);
|
||||
|
||||
// senha nova funciona
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAVendedorEmail, senha: novaSenha })
|
||||
.expect(200);
|
||||
|
||||
// deixa a senha como estava para não afetar outros testes que rodem depois
|
||||
await prisma.tenant_users.update({
|
||||
where: { id: tenantAVendedorId },
|
||||
data: { senha_hash: await hashPassword(senha) },
|
||||
});
|
||||
});
|
||||
|
||||
it('token inválido retorna 401', async () => {
|
||||
await request(httpServer)
|
||||
.post('/auth/password/reset')
|
||||
.send({ token: 'token-invalido', nova_senha: 'QualquerSenha#26' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('senha fraca no reset é rejeitada (400) antes de validar o token', async () => {
|
||||
await request(httpServer)
|
||||
.post('/auth/password/reset')
|
||||
.send({ token: 'token-invalido', nova_senha: '123' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guards: MasterGuard / TenantGuard / RolesGuard', () => {
|
||||
it('sem token retorna 401', async () => {
|
||||
await request(httpServer).get('/auth/me').expect(401);
|
||||
});
|
||||
|
||||
it('token de tenant não acessa rota master (403)', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
await request(httpServer)
|
||||
.get('/auth/master/admins')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('token de master não acessa rota de tenant (403)', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: masterEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('token master acessa rota master normalmente', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: masterEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get('/auth/master/admins')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect((res.body as Array<{ id: string }>).some((a) => a.id === masterId)).toBe(true);
|
||||
});
|
||||
|
||||
it('vendedor tentando acessar rota restrita a tenant_admin é bloqueado pelo RolesGuard (403)', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAVendedorEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('tenant_admin acessa a lista de usuários do próprio tenant', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(200);
|
||||
|
||||
const ids = (res.body as Array<{ id: string }>).map((u) => u.id);
|
||||
expect(ids).toEqual(expect.arrayContaining([tenantAAdminId, tenantAVendedorId]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolamento entre tenants via RLS', () => {
|
||||
it('dado de um tenant nunca vaza para outro, mesmo forçando tenant_id de outro tenant na query string', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantAAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get(`/auth/tenant/users?tenant_id=${tenantBId}`)
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(200);
|
||||
|
||||
const ids = (res.body as Array<{ id: string }>).map((u) => u.id);
|
||||
|
||||
// só usuários do tenant A (dono da sessão), nunca o do tenant B —
|
||||
// mesmo pedindo explicitamente o tenant_id de B na query string.
|
||||
expect(ids).toEqual(expect.arrayContaining([tenantAAdminId, tenantAVendedorId]));
|
||||
expect(ids).not.toContain(tenantBAdminId);
|
||||
});
|
||||
|
||||
it('login como tenant B só enxerga o próprio usuário, nunca os do tenant A', async () => {
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantBAdminEmail, senha })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${(login.body as TokenPair).access_token}`)
|
||||
.expect(200);
|
||||
|
||||
const ids = (res.body as Array<{ id: string }>).map((u) => u.id);
|
||||
expect(ids).toEqual([tenantBAdminId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proteção contra força bruta', () => {
|
||||
it('bloqueia com 429 após 5 tentativas de login falhas, mesmo se a senha correta vier em seguida', async () => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantBAdminEmail, senha: 'senha-errada' })
|
||||
.expect(401);
|
||||
}
|
||||
|
||||
// a 6ª tentativa é bloqueada por 429 mesmo com a senha correta
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: tenantBAdminEmail, senha })
|
||||
.expect(429);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request, { Response } from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { HealthStatus } from '../src/modules/health/health.service';
|
||||
|
||||
describe('HealthController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health retorna 200 com status da API e do banco', () => {
|
||||
return request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||
.get('/health')
|
||||
.expect(200)
|
||||
.expect((res: Response) => {
|
||||
const body = res.body as HealthStatus;
|
||||
expect(body.status).toBeDefined();
|
||||
expect(body.database).toBeDefined();
|
||||
expect(body.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/config/prisma.service';
|
||||
import { hashPassword } from '../src/modules/auth/password.util';
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
function referenciaAtual(): string {
|
||||
const agora = new Date();
|
||||
return `${agora.getUTCFullYear()}-${String(agora.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
describe('Master — Planos, Tenants e Faturas (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let httpServer: Parameters<typeof request>[0];
|
||||
|
||||
const senha = 'SenhaForteDemo#26';
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const masterEmail = `master-etapa3-${suffix}@teste.com`;
|
||||
|
||||
let masterId: string;
|
||||
let masterToken: string;
|
||||
|
||||
const tenantIds: string[] = [];
|
||||
const planoIds: string[] = [];
|
||||
const tenantUserIds: 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}`),
|
||||
patch: (url: string) =>
|
||||
request(httpServer).patch(url).set('Authorization', `Bearer ${masterToken}`),
|
||||
delete: (url: string) =>
|
||||
request(httpServer).delete(url).set('Authorization', `Bearer ${masterToken}`),
|
||||
};
|
||||
}
|
||||
|
||||
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_customers: 100,
|
||||
limite_usuarios: 5,
|
||||
limite_instancias_whatsapp: 1,
|
||||
...overrides,
|
||||
})
|
||||
.expect(201);
|
||||
planoIds.push((res.body as { id: string }).id);
|
||||
return res.body as { id: string; valor_mensal: string; ativo: boolean };
|
||||
}
|
||||
|
||||
async function criarTenant(planoId: string, overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
|
||||
const res = await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: `Tenant ${suffix} ${randomUUID().slice(0, 6)}`,
|
||||
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
|
||||
plano_id: planoId,
|
||||
admin: { nome: 'Admin de Teste', email: emailAdmin },
|
||||
...overrides,
|
||||
})
|
||||
.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);
|
||||
return body;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
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);
|
||||
|
||||
const senha_hash = await hashPassword(senha);
|
||||
const master = await prisma.master_admins.create({
|
||||
data: { nome: 'Master Etapa 3', email: masterEmail, senha_hash },
|
||||
});
|
||||
masterId = master.id;
|
||||
|
||||
masterToken = await authMaster();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// tenants em cascata levam tenant_users e tenant_faturas junto
|
||||
// (onDelete: Cascade no schema); planos só depois, pois tenants tem FK
|
||||
// pra planos.
|
||||
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
|
||||
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
|
||||
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();
|
||||
});
|
||||
|
||||
describe('CRUD de planos', () => {
|
||||
it('cria plano válido', async () => {
|
||||
const plano = await criarPlano({ valor_mensal: 249.5 });
|
||||
expect(plano.valor_mensal).toBe('249.5');
|
||||
expect(plano.ativo).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita valor_mensal <= 0', async () => {
|
||||
await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({ nome: 'Plano inválido', valor_mensal: 0 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejeita limite <= 0 quando informado', async () => {
|
||||
await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({ nome: 'Plano inválido', valor_mensal: 10, limite_customers: 0 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('lista, detalha e atualiza', async () => {
|
||||
const plano = await criarPlano();
|
||||
|
||||
const lista = await asMaster().get('/master/planos').expect(200);
|
||||
expect((lista.body as Array<{ id: string }>).some((p) => p.id === plano.id)).toBe(true);
|
||||
|
||||
const detalhe = await asMaster().get(`/master/planos/${plano.id}`).expect(200);
|
||||
expect((detalhe.body as { id: string }).id).toBe(plano.id);
|
||||
|
||||
const atualizado = await asMaster()
|
||||
.put(`/master/planos/${plano.id}`)
|
||||
.send({ nome: 'Plano Atualizado', valor_mensal: 299.9 })
|
||||
.expect(200);
|
||||
expect((atualizado.body as { nome: string }).nome).toBe('Plano Atualizado');
|
||||
});
|
||||
|
||||
it('exclui plano não usado por nenhum tenant', async () => {
|
||||
const plano = await criarPlano();
|
||||
|
||||
const res = await asMaster().delete(`/master/planos/${plano.id}`).expect(200);
|
||||
expect(res.body).toMatchObject({ deletado: true, desativado: false });
|
||||
|
||||
await asMaster().get(`/master/planos/${plano.id}`).expect(404);
|
||||
// já foi removido do array de cleanup implicitamente — não afeta o afterAll
|
||||
// (deleteMany ignora ids que não existem mais).
|
||||
});
|
||||
|
||||
it('não permite excluir plano em uso por tenant ativo — desativa em vez disso', async () => {
|
||||
const plano = await criarPlano();
|
||||
await criarTenant(plano.id);
|
||||
|
||||
const res = await asMaster().delete(`/master/planos/${plano.id}`).expect(200);
|
||||
expect(res.body).toMatchObject({ deletado: false, desativado: true });
|
||||
|
||||
const detalhe = await asMaster().get(`/master/planos/${plano.id}`).expect(200);
|
||||
expect((detalhe.body as { ativo: boolean }).ativo).toBe(false);
|
||||
});
|
||||
|
||||
it('todos os endpoints de planos são bloqueados com token de tenant_user', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { admin } = await criarTenant(plano.id);
|
||||
|
||||
const loginTenant = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (loginTenant.body as TokenPair).access_token;
|
||||
|
||||
await request(httpServer)
|
||||
.get('/master/planos')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tenants', () => {
|
||||
it('cria tenant com o primeiro tenant_admin funcional (login testado) e grava auditoria', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant, admin } = await criarTenant(plano.id);
|
||||
|
||||
expect(tenant).toMatchObject({ status: 'trial' });
|
||||
expect(admin.senha_provisoria).toEqual(expect.any(String));
|
||||
expect(admin.senha_provisoria.length).toBeGreaterThanOrEqual(10);
|
||||
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
expect((login.body as TokenPair).access_token).toEqual(expect.any(String));
|
||||
|
||||
const log = await prisma.audit_logs.findFirst({
|
||||
where: { acao: 'criar_tenant', entidade_id: tenant.id },
|
||||
});
|
||||
expect(log).not.toBeNull();
|
||||
expect(log?.usuario_id).toBe(masterId);
|
||||
});
|
||||
|
||||
it('rejeita criação com plano inexistente', async () => {
|
||||
await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: 'Tenant Plano Inválido',
|
||||
email_contato: `x-${randomUUID()}@teste.com`,
|
||||
plano_id: randomUUID(),
|
||||
admin: { nome: 'Admin', email: `y-${randomUUID()}@teste.com` },
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('listagem com filtro por status e busca funciona', async () => {
|
||||
const plano = await criarPlano();
|
||||
const marcador = `buscamarcador${suffix}`;
|
||||
const { tenant } = await criarTenant(plano.id, { razao_social: `Empresa ${marcador} Ltda` });
|
||||
|
||||
const porBusca = await asMaster().get(`/master/tenants?busca=${marcador}`).expect(200);
|
||||
const idsBusca = (porBusca.body as { data: Array<{ id: string }> }).data.map((t) => t.id);
|
||||
expect(idsBusca).toEqual([tenant.id]);
|
||||
|
||||
const porStatus = await asMaster()
|
||||
.get(`/master/tenants?busca=${marcador}&status=trial`)
|
||||
.expect(200);
|
||||
expect((porStatus.body as { data: Array<{ id: string }> }).data.map((t) => t.id)).toEqual([
|
||||
tenant.id,
|
||||
]);
|
||||
|
||||
const statusErrado = await asMaster()
|
||||
.get(`/master/tenants?busca=${marcador}&status=cancelado`)
|
||||
.expect(200);
|
||||
expect((statusErrado.body as { data: unknown[] }).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('detalhe do tenant traz plano, faturas e contadores agregados', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
|
||||
await asMaster().post('/master/faturas/gerar-mes').send({}).expect(200);
|
||||
|
||||
const detalhe = await asMaster().get(`/master/tenants/${tenant.id}`).expect(200);
|
||||
const body = detalhe.body as {
|
||||
plano: { id: string };
|
||||
faturas: unknown[];
|
||||
_count: { usuarios: number; customers: number; vendas: number };
|
||||
};
|
||||
expect(body.plano.id).toBe(plano.id);
|
||||
expect(body._count).toMatchObject({ usuarios: 1, customers: 0, vendas: 0 });
|
||||
// o tenant começa como `trial`; gerar-mes só gera fatura pra quem já
|
||||
// está `ativo`, então aqui a lista de faturas fica vazia mesmo.
|
||||
expect(Array.isArray(body.faturas)).toBe(true);
|
||||
});
|
||||
|
||||
it('atualiza dados cadastrais via PUT', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
|
||||
const atualizado = await asMaster()
|
||||
.put(`/master/tenants/${tenant.id}`)
|
||||
.send({ telefone: '11999998888' })
|
||||
.expect(200);
|
||||
expect((atualizado.body as { telefone: string }).telefone).toBe('11999998888');
|
||||
});
|
||||
|
||||
it('muda status para suspenso: bloqueia login novo e revoga acesso de token já emitido (TenantGuard) + audita', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant, admin } = await criarTenant(plano.id);
|
||||
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (login.body as TokenPair).access_token;
|
||||
|
||||
// antes de suspender, o token funciona normalmente numa rota com TenantGuard
|
||||
await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(200);
|
||||
|
||||
await asMaster()
|
||||
.patch(`/master/tenants/${tenant.id}/status`)
|
||||
.send({ status: 'suspenso' })
|
||||
.expect(200);
|
||||
|
||||
// login novo passa a ser rejeitado
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(401);
|
||||
|
||||
// o token de acesso emitido antes da suspensão, ainda não expirado,
|
||||
// é bloqueado pelo TenantGuard (checa o status do tenant a cada request)
|
||||
await request(httpServer)
|
||||
.get('/auth/tenant/users')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(403);
|
||||
|
||||
const log = await prisma.audit_logs.findFirst({
|
||||
where: { acao: 'mudar_status_tenant', entidade_id: tenant.id },
|
||||
});
|
||||
expect(log?.detalhes).toMatchObject({ de: 'trial', para: 'suspenso' });
|
||||
});
|
||||
|
||||
it('muda status para cancelado também bloqueia o acesso', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant, admin } = await criarTenant(plano.id);
|
||||
|
||||
await asMaster()
|
||||
.patch(`/master/tenants/${tenant.id}/status`)
|
||||
.send({ status: 'cancelado' })
|
||||
.expect(200);
|
||||
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('todos os endpoints de tenants são bloqueados com token de tenant_user', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { admin } = await criarTenant(plano.id);
|
||||
|
||||
const loginTenant = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (loginTenant.body as TokenPair).access_token;
|
||||
|
||||
await request(httpServer)
|
||||
.get('/master/tenants')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Faturas', () => {
|
||||
it('gerar-mes não duplica fatura já existente para o mesmo tenant/referência', async () => {
|
||||
const plano = await criarPlano({ valor_mensal: 150 });
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } });
|
||||
|
||||
const referencia = referenciaAtual();
|
||||
|
||||
await asMaster().post('/master/faturas/gerar-mes').send({ referencia }).expect(200);
|
||||
await asMaster().post('/master/faturas/gerar-mes').send({ referencia }).expect(200);
|
||||
|
||||
const faturas = await prisma.tenant_faturas.findMany({
|
||||
where: { tenant_id: tenant.id, referencia },
|
||||
});
|
||||
expect(faturas).toHaveLength(1);
|
||||
expect(faturas[0].valor.toString()).toBe('150');
|
||||
});
|
||||
|
||||
it('marcar fatura como paga reverte tenant inadimplente -> ativo e audita', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'inadimplente' } });
|
||||
|
||||
const fatura = await prisma.tenant_faturas.create({
|
||||
data: {
|
||||
tenant_id: tenant.id,
|
||||
referencia: '2020-01',
|
||||
valor: 100,
|
||||
vencimento: new Date('2020-01-10T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await asMaster().patch(`/master/faturas/${fatura.id}/marcar-pago`).expect(200);
|
||||
expect((res.body as { status: string; pago_em: string }).status).toBe('pago');
|
||||
|
||||
const tenantAtualizado = await prisma.tenants.findUnique({ where: { id: tenant.id } });
|
||||
expect(tenantAtualizado?.status).toBe('ativo');
|
||||
|
||||
// idempotência: marcar de novo uma fatura já paga é rejeitado
|
||||
await asMaster().patch(`/master/faturas/${fatura.id}/marcar-pago`).expect(409);
|
||||
|
||||
const log = await prisma.audit_logs.findFirst({
|
||||
where: { acao: 'marcar_fatura_paga', entidade_id: fatura.id },
|
||||
});
|
||||
expect(log).not.toBeNull();
|
||||
});
|
||||
|
||||
it('atualizar-atrasadas marca fatura vencida como atrasada e tenant como inadimplente', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } });
|
||||
|
||||
const fatura = await prisma.tenant_faturas.create({
|
||||
data: {
|
||||
tenant_id: tenant.id,
|
||||
referencia: '2020-02',
|
||||
valor: 100,
|
||||
vencimento: new Date('2020-02-10T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await asMaster().post('/master/faturas/atualizar-atrasadas').send({}).expect(200);
|
||||
|
||||
const faturaAtualizada = await prisma.tenant_faturas.findUnique({ where: { id: fatura.id } });
|
||||
expect(faturaAtualizada?.status).toBe('atrasado');
|
||||
|
||||
const tenantAtualizado = await prisma.tenants.findUnique({ where: { id: tenant.id } });
|
||||
expect(tenantAtualizado?.status).toBe('inadimplente');
|
||||
|
||||
const log = await prisma.audit_logs.findFirst({
|
||||
where: { acao: 'atualizar_faturas_atrasadas' },
|
||||
orderBy: { criado_em: 'desc' },
|
||||
});
|
||||
expect((log?.detalhes as { tenant_ids: string[] })?.tenant_ids).toContain(tenant.id);
|
||||
});
|
||||
|
||||
it('listagem de faturas filtra por tenant e status', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
await prisma.tenant_faturas.create({
|
||||
data: {
|
||||
tenant_id: tenant.id,
|
||||
referencia: '2020-03',
|
||||
valor: 50,
|
||||
vencimento: new Date('2020-03-10'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await asMaster()
|
||||
.get(`/master/faturas?tenant_id=${tenant.id}&status=pendente`)
|
||||
.expect(200);
|
||||
const data = (res.body as { data: Array<{ tenant_id: string; status: string }> }).data;
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data.every((f) => f.tenant_id === tenant.id && f.status === 'pendente')).toBe(true);
|
||||
});
|
||||
|
||||
it('todos os endpoints de faturas são bloqueados com token de tenant_user', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { admin } = await criarTenant(plano.id);
|
||||
|
||||
const loginTenant = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (loginTenant.body as TokenPair).access_token;
|
||||
|
||||
await request(httpServer)
|
||||
.get('/master/faturas')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('retorna números corretos (conferidos por delta antes/depois)', async () => {
|
||||
const antes = await asMaster().get('/master/dashboard').expect(200);
|
||||
const bodyAntes = antes.body as {
|
||||
tenants_por_status: Record<string, number>;
|
||||
mrr: string;
|
||||
total_recebido_mes: string;
|
||||
total_em_aberto: string;
|
||||
};
|
||||
|
||||
const plano = await criarPlano({ valor_mensal: 321.5 });
|
||||
const { tenant } = await criarTenant(plano.id);
|
||||
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } });
|
||||
|
||||
await prisma.tenant_faturas.create({
|
||||
data: {
|
||||
tenant_id: tenant.id,
|
||||
referencia: '2020-04',
|
||||
valor: 77.25,
|
||||
status: 'pago',
|
||||
pago_em: new Date(),
|
||||
vencimento: new Date('2020-04-10'),
|
||||
},
|
||||
});
|
||||
await prisma.tenant_faturas.create({
|
||||
data: {
|
||||
tenant_id: tenant.id,
|
||||
referencia: '2020-05',
|
||||
valor: 33.1,
|
||||
status: 'pendente',
|
||||
vencimento: new Date('2020-05-10'),
|
||||
},
|
||||
});
|
||||
|
||||
const depois = await asMaster().get('/master/dashboard').expect(200);
|
||||
const bodyDepois = depois.body as typeof bodyAntes;
|
||||
|
||||
expect(bodyDepois.tenants_por_status.ativo).toBeGreaterThanOrEqual(
|
||||
(bodyAntes.tenants_por_status.ativo ?? 0) + 1,
|
||||
);
|
||||
|
||||
const deltaMrr = Number(bodyDepois.mrr) - Number(bodyAntes.mrr);
|
||||
expect(deltaMrr).toBeCloseTo(321.5, 2);
|
||||
|
||||
const deltaRecebido =
|
||||
Number(bodyDepois.total_recebido_mes) - Number(bodyAntes.total_recebido_mes);
|
||||
expect(deltaRecebido).toBeCloseTo(77.25, 2);
|
||||
|
||||
const deltaAberto = Number(bodyDepois.total_em_aberto) - Number(bodyAntes.total_em_aberto);
|
||||
expect(deltaAberto).toBeCloseTo(33.1, 2);
|
||||
});
|
||||
|
||||
it('bloqueado com token de tenant_user', async () => {
|
||||
const plano = await criarPlano();
|
||||
const { admin } = await criarTenant(plano.id);
|
||||
|
||||
const loginTenant = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: admin.email, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (loginTenant.body as TokenPair).access_token;
|
||||
|
||||
await request(httpServer)
|
||||
.get('/master/dashboard')
|
||||
.set('Authorization', `Bearer ${tenantToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user