Adiciona o frontend Next.js em frontend/ (setup, design system, autenticação/tema, layout/navegação, área master, área tenant completa: clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas, equipe e perfil). Estende o backend de forma aditiva e autorizada: cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe + edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
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';
|
|
import { applySecurityConfig } from '../src/security.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;
|
|
}
|
|
|
|
/** Extrai `refresh_token=valor` do `Set-Cookie` de uma resposta, pronto pra reenviar no header `Cookie`. */
|
|
function refreshCookieHeader(res: request.Response): string {
|
|
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
|
const cookie = setCookie?.find((c) => c.startsWith('refresh_token='));
|
|
if (!cookie) throw new Error('refresh_token cookie não encontrado na resposta');
|
|
return cookie.split(';')[0];
|
|
}
|
|
|
|
describe('Auth (e2e)', () => {
|
|
let app: NestExpressApplication;
|
|
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;
|
|
const masterEmailConfigIds: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication<NestExpressApplication>();
|
|
applySecurityConfig(app, moduleFixture.get(ConfigService));
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
|
);
|
|
await app.init();
|
|
httpServer = app.getHttpServer();
|
|
|
|
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 também seta o refresh token como cookie httpOnly (Etapa 13 do frontend)', async () => {
|
|
const res = await request(httpServer)
|
|
.post('/auth/tenant/login')
|
|
.send({ email: tenantAAdminEmail, senha })
|
|
.expect(200);
|
|
|
|
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
|
const refreshCookie = setCookie?.find((c) => c.startsWith('refresh_token='));
|
|
expect(refreshCookie).toBeDefined();
|
|
expect(refreshCookie).toMatch(/HttpOnly/i);
|
|
expect(refreshCookie).toMatch(/Path=\/auth/i);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('funciona só com o cookie httpOnly, sem nada no corpo (fluxo real do frontend)', async () => {
|
|
const login = await request(httpServer)
|
|
.post('/auth/tenant/login')
|
|
.send({ email: tenantAVendedorEmail, senha })
|
|
.expect(200);
|
|
|
|
const res = await request(httpServer)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', [refreshCookieHeader(login)])
|
|
.send({})
|
|
.expect(200);
|
|
|
|
// resposta também renova o cookie (rotação)
|
|
expect(refreshCookieHeader(res)).toMatch(/^refresh_token=.+/);
|
|
});
|
|
|
|
it('sem refresh token nem no corpo nem no cookie retorna 401', async () => {
|
|
await request(httpServer).post('/auth/refresh').send({}).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);
|
|
});
|
|
|
|
it('funciona só com o cookie httpOnly e limpa o cookie na resposta', async () => {
|
|
const login = await request(httpServer)
|
|
.post('/auth/tenant/login')
|
|
.send({ email: tenantAAdminEmail, senha })
|
|
.expect(200);
|
|
|
|
const cookie = refreshCookieHeader(login);
|
|
|
|
const logoutRes = await request(httpServer)
|
|
.post('/auth/logout')
|
|
.set('Cookie', [cookie])
|
|
.send({})
|
|
.expect(204);
|
|
|
|
const clearedCookie = (logoutRes.headers['set-cookie'] as unknown as string[])?.find((c) =>
|
|
c.startsWith('refresh_token='),
|
|
);
|
|
expect(clearedCookie).toMatch(/refresh_token=;/);
|
|
|
|
await request(httpServer).post('/auth/refresh').set('Cookie', [cookie]).send({}).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 = /(?<![\w-])[\w-]{20,}(?![\w-])/.exec(corpoEmail)?.[0];
|
|
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);
|
|
});
|
|
});
|
|
});
|