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'; // O reset de senha (Etapa 8) manda o token por e-mail de verdade — sem SMTP // real disponível neste ambiente, mocka `nodemailer` (mesmo padrão de // `integracoes.e2e-spec.ts`) e extrai o token do corpo do e-mail "enviado". 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; token_type: string; expires_in: number; } describe('Auth (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let httpServer: Parameters[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; const masterEmailConfigIds: 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[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_email_config.deleteMany({ where: { id: { in: masterEmailConfigIds } } }); 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', () => { beforeAll(async () => { // Reset de senha manda o token por e-mail de verdade (Etapa 8) — sem // config de e-mail padrão ativa, `forgotPassword` engoliria o erro de // envio (nunca deixa a falta de SMTP vazar informação pela resposta // genérica) e nenhum e-mail chegaria a ser "enviado" pro mock capturar. const masterLogin = await request(httpServer) .post('/auth/master/login') .send({ email: masterEmail, senha }) .expect(200); const masterToken = (masterLogin.body as TokenPair).access_token; const config = await request(httpServer) .post('/master/email-config') .set('Authorization', `Bearer ${masterToken}`) .send({ smtp_host: 'smtp.exemplo.com', smtp_porta: 587, smtp_usuario: 'no-reply', smtp_senha: 'senha-smtp-secreta', email_remetente: 'no-reply@seconectai.com.br', }) .expect(201); masterEmailConfigIds.push((config.body as { id: string }).id); }); it('gera token de uso único, com expiração, e permite redefinir a senha', async () => { mockSendMail.mockClear(); await request(httpServer) .post('/auth/password/forgot') .send({ email: tenantAVendedorEmail, usuario_tipo: 'tenant_user' }) .expect(200); expect(mockSendMail).toHaveBeenCalledTimes(1); const [emailEnviado] = mockSendMail.mock.calls[0] as [{ html: string }]; const corpoEmail = String(emailEnviado.html); const token = /(? { 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); }); }); });