Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
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>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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';
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface EquipeUser {
|
||||
id: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
role: string;
|
||||
ativo: boolean;
|
||||
criado_em: string;
|
||||
senha_provisoria?: string;
|
||||
}
|
||||
|
||||
describe('Equipe (tenant_users) e Perfil — texto (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-etapa18-${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}`),
|
||||
};
|
||||
}
|
||||
|
||||
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}`),
|
||||
patch: (url: string) =>
|
||||
request(httpServer).patch(url).set('Authorization', `Bearer ${token}`),
|
||||
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function criarPlano(limiteUsuarios?: number) {
|
||||
const res = await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({
|
||||
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
|
||||
valor_mensal: 199.9,
|
||||
...(limiteUsuarios !== undefined ? { limite_usuarios: limiteUsuarios } : {}),
|
||||
})
|
||||
.expect(201);
|
||||
planoIds.push((res.body as { id: string }).id);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
async function criarTenantComAdmin(limiteUsuarios?: number) {
|
||||
const plano = await criarPlano(limiteUsuarios);
|
||||
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
|
||||
const res = await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: `Tenant Etapa18 ${suffix} ${randomUUID().slice(0, 6)}`,
|
||||
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
|
||||
plano_id: plano.id,
|
||||
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 {
|
||||
tenantId: body.tenant.id,
|
||||
adminId: body.admin.id,
|
||||
adminEmail: body.admin.email,
|
||||
adminSenha: body.admin.senha_provisoria,
|
||||
token: (login.body as TokenPair).access_token,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestExpressApplication>();
|
||||
applySecurityConfig(app, app.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 Etapa 18', email: masterEmail, senha_hash },
|
||||
});
|
||||
masterId = master.id;
|
||||
|
||||
masterToken = await authMaster();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
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('GET /tenant/equipe', () => {
|
||||
it('lista o tenant_admin criado junto com o tenant', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const res = await asTenant(tenant.token).get('/tenant/equipe').expect(200);
|
||||
const lista = res.body as EquipeUser[];
|
||||
expect(lista).toHaveLength(1);
|
||||
expect(lista[0]).toMatchObject({
|
||||
email: tenant.adminEmail,
|
||||
role: 'tenant_admin',
|
||||
ativo: true,
|
||||
});
|
||||
expect(lista[0]).not.toHaveProperty('senha_provisoria');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tenant/equipe', () => {
|
||||
it('cria um usuário em cada papel possível, com senha provisória exibida uma única vez', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const papeis = ['tenant_admin', 'vendedor', 'financeiro', 'visualizador'] as const;
|
||||
|
||||
for (const role of papeis) {
|
||||
const res = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: `Usuário ${role}`, email: `${role}-${suffix}@teste.com`, role })
|
||||
.expect(201);
|
||||
const body = res.body as EquipeUser;
|
||||
tenantUserIds.push(body.id);
|
||||
expect(body.role).toBe(role);
|
||||
expect(body.ativo).toBe(true);
|
||||
expect(typeof body.senha_provisoria).toBe('string');
|
||||
expect((body.senha_provisoria as string).length).toBeGreaterThanOrEqual(10);
|
||||
|
||||
// a senha provisória nunca reaparece em GET
|
||||
const lista = await asTenant(tenant.token).get('/tenant/equipe').expect(200);
|
||||
const criado = (lista.body as EquipeUser[]).find((u) => u.id === body.id);
|
||||
expect(criado).not.toHaveProperty('senha_provisoria');
|
||||
}
|
||||
});
|
||||
|
||||
it('aceita senha explícita forte e rejeita senha explícita fraca', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
const res = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({
|
||||
nome: 'Com senha própria',
|
||||
email: `com-senha-${suffix}@teste.com`,
|
||||
role: 'vendedor',
|
||||
senha: 'MinhaSenhaForte#2026',
|
||||
})
|
||||
.expect(201);
|
||||
tenantUserIds.push((res.body as EquipeUser).id);
|
||||
expect((res.body as EquipeUser).senha_provisoria).toBe('MinhaSenhaForte#2026');
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({
|
||||
nome: 'Senha fraca',
|
||||
email: `senha-fraca-${suffix}@teste.com`,
|
||||
role: 'vendedor',
|
||||
senha: '123',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejeita payload sem role e e-mail duplicado dentro do tenant retorna 409', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Sem papel', email: `sem-papel-${suffix}@teste.com` })
|
||||
.expect(400);
|
||||
|
||||
const emailDuplicado = `duplicado-${suffix}@teste.com`;
|
||||
const primeiro = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Primeiro', email: emailDuplicado, role: 'vendedor' })
|
||||
.expect(201);
|
||||
tenantUserIds.push((primeiro.body as EquipeUser).id);
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Segundo', email: emailDuplicado, role: 'financeiro' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('respeita limite_usuarios do plano, com mensagem clara', async () => {
|
||||
const tenant = await criarTenantComAdmin(1); // só o próprio tenant_admin já ocupa o limite
|
||||
|
||||
const res = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({
|
||||
nome: 'Além do limite',
|
||||
email: `alem-limite-${suffix}@teste.com`,
|
||||
role: 'vendedor',
|
||||
})
|
||||
.expect(400);
|
||||
expect((res.body as { message: string }).message).toMatch(/Limite de 1 usuário/);
|
||||
|
||||
// eleva o limite via SQL (mesma técnica da Etapa 16 para limite_customers) e confirma que passa a funcionar
|
||||
await prisma.planos.update({
|
||||
where: {
|
||||
id: (await prisma.tenants.findUniqueOrThrow({ where: { id: tenant.tenantId } }))
|
||||
.plano_id as string,
|
||||
},
|
||||
data: { limite_usuarios: 2 },
|
||||
});
|
||||
|
||||
const ok = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({
|
||||
nome: 'Dentro do limite',
|
||||
email: `dentro-limite-${suffix}@teste.com`,
|
||||
role: 'vendedor',
|
||||
})
|
||||
.expect(201);
|
||||
tenantUserIds.push((ok.body as EquipeUser).id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /tenant/equipe/:id', () => {
|
||||
it('altera o papel de um usuário', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const criado = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Muda papel', email: `muda-papel-${suffix}@teste.com`, role: 'vendedor' })
|
||||
.expect(201);
|
||||
const id = (criado.body as EquipeUser).id;
|
||||
tenantUserIds.push(id);
|
||||
|
||||
const atualizado = await asTenant(tenant.token)
|
||||
.patch(`/tenant/equipe/${id}`)
|
||||
.send({ role: 'financeiro' })
|
||||
.expect(200);
|
||||
expect((atualizado.body as EquipeUser).role).toBe('financeiro');
|
||||
});
|
||||
|
||||
it('desativa um usuário: refresh token é revogado e login volta a ser rejeitado', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const emailNovo = `desativa-${suffix}@teste.com`;
|
||||
const senhaNova = 'SenhaForteNova#26';
|
||||
const criado = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Vai ser desativado', email: emailNovo, role: 'vendedor', senha: senhaNova })
|
||||
.expect(201);
|
||||
const id = (criado.body as EquipeUser).id;
|
||||
tenantUserIds.push(id);
|
||||
|
||||
const loginNovo = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: emailNovo, senha: senhaNova })
|
||||
.expect(200);
|
||||
const refreshToken = (loginNovo.body as TokenPair).refresh_token;
|
||||
|
||||
await asTenant(tenant.token).patch(`/tenant/equipe/${id}`).send({ ativo: false }).expect(200);
|
||||
|
||||
// login novo rejeitado
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: emailNovo, senha: senhaNova })
|
||||
.expect(401);
|
||||
|
||||
// refresh do token emitido antes da desativação também rejeitado (revogado)
|
||||
await request(httpServer)
|
||||
.post('/auth/refresh')
|
||||
.send({ refresh_token: refreshToken })
|
||||
.expect(401);
|
||||
|
||||
// reativar permite login de novo
|
||||
await asTenant(tenant.token).patch(`/tenant/equipe/${id}`).send({ ativo: true }).expect(200);
|
||||
await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: emailNovo, senha: senhaNova })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('bloqueia o próprio tenant_admin de se autodesativar ou se autorrebaixar', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.patch(`/tenant/equipe/${tenant.adminId}`)
|
||||
.send({ ativo: false })
|
||||
.expect(400);
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.patch(`/tenant/equipe/${tenant.adminId}`)
|
||||
.send({ role: 'vendedor' })
|
||||
.expect(400);
|
||||
|
||||
// continua ativo/admin
|
||||
const lista = await asTenant(tenant.token).get('/tenant/equipe').expect(200);
|
||||
const eu = (lista.body as EquipeUser[]).find((u) => u.id === tenant.adminId);
|
||||
expect(eu).toMatchObject({ ativo: true, role: 'tenant_admin' });
|
||||
});
|
||||
|
||||
it('id inexistente retorna 404', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
await asTenant(tenant.token)
|
||||
.patch(`/tenant/equipe/${randomUUID()}`)
|
||||
.send({ ativo: false })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolamento por RLS entre tenants', () => {
|
||||
it('tenant B não vê nem altera tenant_users do tenant A (404 pelo UUID exato)', async () => {
|
||||
const tenantA = await criarTenantComAdmin();
|
||||
const tenantB = await criarTenantComAdmin();
|
||||
|
||||
const listaB = await asTenant(tenantB.token).get('/tenant/equipe').expect(200);
|
||||
expect((listaB.body as EquipeUser[]).some((u) => u.id === tenantA.adminId)).toBe(false);
|
||||
|
||||
await asTenant(tenantB.token)
|
||||
.patch(`/tenant/equipe/${tenantA.adminId}`)
|
||||
.send({ ativo: false })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controle de acesso', () => {
|
||||
it('bloqueia todos os endpoints para papéis diferentes de tenant_admin', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const vendedorEmail = `vendedor-acesso-${suffix}@teste.com`;
|
||||
const vendedorSenha = 'SenhaForteVendedor#26';
|
||||
const criado = await asTenant(tenant.token)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'Vendedor', email: vendedorEmail, role: 'vendedor', senha: vendedorSenha })
|
||||
.expect(201);
|
||||
tenantUserIds.push((criado.body as EquipeUser).id);
|
||||
|
||||
const loginVendedor = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: vendedorEmail, senha: vendedorSenha })
|
||||
.expect(200);
|
||||
const tokenVendedor = (loginVendedor.body as TokenPair).access_token;
|
||||
|
||||
await asTenant(tokenVendedor).get('/tenant/equipe').expect(403);
|
||||
await asTenant(tokenVendedor)
|
||||
.post('/tenant/equipe')
|
||||
.send({ nome: 'X', email: `x-${suffix}@teste.com`, role: 'vendedor' })
|
||||
.expect(403);
|
||||
await asTenant(tokenVendedor)
|
||||
.patch(`/tenant/equipe/${tenant.adminId}`)
|
||||
.send({ ativo: false })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('bloqueia todos os endpoints para token master "puro"', async () => {
|
||||
await asMaster().get('/tenant/equipe').expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tenant/perfil — nome_fantasia/razao_social', () => {
|
||||
it('atualiza os dois campos de uma vez', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
const res = await asTenant(tenant.token)
|
||||
.put('/tenant/perfil')
|
||||
.send({ nome_fantasia: 'Fantasia Nova', razao_social: 'Razão Social Nova Ltda' })
|
||||
.expect(200);
|
||||
expect(res.body).toMatchObject({
|
||||
nome_fantasia: 'Fantasia Nova',
|
||||
razao_social: 'Razão Social Nova Ltda',
|
||||
});
|
||||
|
||||
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
expect(perfil.body).toMatchObject({
|
||||
nome_fantasia: 'Fantasia Nova',
|
||||
razao_social: 'Razão Social Nova Ltda',
|
||||
});
|
||||
});
|
||||
|
||||
it('atualização parcial: só nome_fantasia, razao_social permanece inalterada', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
const original = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
const razaoOriginal = (original.body as { razao_social: string }).razao_social;
|
||||
|
||||
const res = await asTenant(tenant.token)
|
||||
.put('/tenant/perfil')
|
||||
.send({ nome_fantasia: 'Só Fantasia Mudou' })
|
||||
.expect(200);
|
||||
expect((res.body as { nome_fantasia: string }).nome_fantasia).toBe('Só Fantasia Mudou');
|
||||
expect((res.body as { razao_social: string }).razao_social).toBe(razaoOriginal);
|
||||
});
|
||||
|
||||
it('rejeita razao_social vazia/curta demais', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
await asTenant(tenant.token).put('/tenant/perfil').send({ razao_social: 'A' }).expect(400);
|
||||
});
|
||||
|
||||
it('master "puro" recebe 403', async () => {
|
||||
await asMaster().get('/tenant/perfil').expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user