Etapa 8 — Personalização whitelabel e ajustes finais
Adiciona upload/remoção de logo e cor primária por tenant (módulo tenant-profile, armazenamento local atrás de uma interface StorageService trocável por object storage depois) e um endpoint público de tema para a tela de login carregar antes da autenticação. Revisão de segurança: Helmet e CORS explícito (extraídos para security.util.ts, reutilizado pelos testes e2e que verificam os cabeçalhos), Swagger em /docs cobrindo todos os módulos via o plugin oficial, paginação em alert_templates. Corrige um achado real: o reset de senha logava o token em texto puro desde a Etapa 2 — agora envia por e-mail de verdade. Build de produção do Docker validado (imagem sem devDependencies, app e worker rodando contra Postgres/Redis reais) e README consolidado com o processo de deploy. Fecha o backlog com um teste e2e do fluxo completo: master cria tenant → login → customer/produto → venda com parcelas → parcela paga → dashboard financeiro reflete o pagamento. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+40
-8
@@ -6,6 +6,14 @@ 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;
|
||||
@@ -32,6 +40,7 @@ describe('Auth (e2e)', () => {
|
||||
let tenantAAdminId: string;
|
||||
let tenantAVendedorId: string;
|
||||
let tenantBAdminId: string;
|
||||
const masterEmailConfigIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
@@ -115,6 +124,7 @@ describe('Auth (e2e)', () => {
|
||||
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();
|
||||
@@ -249,21 +259,43 @@ describe('Auth (e2e)', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
mockSendMail.mockClear();
|
||||
|
||||
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(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';
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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 { applySecurityConfig } from '../src/security.util';
|
||||
import { hashPassword } from '../src/modules/auth/password.util';
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Etapa 8, critério de aceite: "escrever um teste E2E que cobre o fluxo
|
||||
* completo mínimo: master cria tenant → tenant faz login → cadastra
|
||||
* customer e produto → faz uma venda com parcelas → marca uma parcela
|
||||
* como paga → confere dashboard financeiro refletindo o pagamento."
|
||||
*
|
||||
* De propósito, não reexercita casos de erro/borda (já cobertos nos specs
|
||||
* dedicados de cada etapa) — só o caminho feliz de ponta a ponta, contra
|
||||
* Postgres/Redis reais, pra provar que os módulos das 8 etapas realmente
|
||||
* se encaixam quando usados em sequência por um único tenant.
|
||||
*/
|
||||
describe('Fluxo completo — do zero ao dashboard financeiro (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-fluxo-${suffix}@teste.com`;
|
||||
|
||||
let masterId: string;
|
||||
let masterToken: string;
|
||||
const tenantIds: string[] = [];
|
||||
const planoIds: string[] = [];
|
||||
const tenantUserIds: string[] = [];
|
||||
|
||||
function asMaster() {
|
||||
return {
|
||||
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}`),
|
||||
};
|
||||
}
|
||||
|
||||
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 Fluxo Completo', email: masterEmail, senha_hash },
|
||||
});
|
||||
masterId = master.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } });
|
||||
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();
|
||||
});
|
||||
|
||||
it('do master criando o tenant até o dashboard financeiro refletir o pagamento', async () => {
|
||||
// 1) master autentica
|
||||
const masterLogin = await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: masterEmail, senha })
|
||||
.expect(200);
|
||||
masterToken = (masterLogin.body as TokenPair).access_token;
|
||||
|
||||
// 2) master cria um plano e, com ele, um tenant novo (já com o primeiro tenant_admin)
|
||||
const plano = await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({ nome: `Plano Fluxo ${suffix}`, valor_mensal: 149.9 })
|
||||
.expect(201);
|
||||
planoIds.push((plano.body as { id: string }).id);
|
||||
|
||||
const emailAdmin = `admin-fluxo-${suffix}@teste.com`;
|
||||
const criarTenant = await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: `Tenant Fluxo Completo ${suffix}`,
|
||||
email_contato: `contato-fluxo-${suffix}@teste.com`,
|
||||
plano_id: (plano.body as { id: string }).id,
|
||||
admin: { nome: 'Admin Fluxo Completo', email: emailAdmin },
|
||||
})
|
||||
.expect(201);
|
||||
const { tenant, admin } = criarTenant.body as {
|
||||
tenant: { id: string };
|
||||
admin: { id: string; senha_provisoria: string };
|
||||
};
|
||||
tenantIds.push(tenant.id);
|
||||
tenantUserIds.push(admin.id);
|
||||
|
||||
// tenant criado nasce em `trial`; ativa pra poder logar sem bloqueio
|
||||
await prisma.tenants.update({ where: { id: tenant.id }, data: { status: 'ativo' } });
|
||||
|
||||
// 3) tenant_admin faz login com a senha provisória
|
||||
const tenantLogin = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: emailAdmin, senha: admin.senha_provisoria })
|
||||
.expect(200);
|
||||
const tenantToken = (tenantLogin.body as TokenPair).access_token;
|
||||
|
||||
// 4) cadastra um customer
|
||||
const customer = await asTenant(tenantToken)
|
||||
.post('/tenant/customers')
|
||||
.send({ razao_social: 'Cliente Fluxo Completo Ltda', email: 'cliente@fluxo-completo.teste' })
|
||||
.expect(201);
|
||||
const customerId = (customer.body as { id: string }).id;
|
||||
|
||||
// 5) cadastra um produto
|
||||
const produto = await asTenant(tenantToken)
|
||||
.post('/tenant/produtos')
|
||||
.send({ tipo: 'produto', nome: 'Produto Fluxo Completo', valor_venda: 300 })
|
||||
.expect(201);
|
||||
const produtoId = (produto.body as { id: string }).id;
|
||||
|
||||
// 6) faz uma venda com 2 parcelas
|
||||
const venda = await asTenant(tenantToken)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 300 }],
|
||||
numero_parcelas: 2,
|
||||
})
|
||||
.expect(201);
|
||||
const vendaBody = venda.body as {
|
||||
id: string;
|
||||
valor_total: string;
|
||||
parcelas: Array<{ id: string; valor: string }>;
|
||||
};
|
||||
expect(Number(vendaBody.valor_total)).toBe(300);
|
||||
expect(vendaBody.parcelas).toHaveLength(2);
|
||||
|
||||
// 7) confere o financeiro ANTES de pagar (delta-based: tenant novo, começa em zero)
|
||||
const financeiroAntes = await asTenant(tenantToken)
|
||||
.get('/tenant/dashboard/financeiro')
|
||||
.expect(200);
|
||||
expect((financeiroAntes.body as { total_recebido_mes: string }).total_recebido_mes).toBe(
|
||||
'0.00',
|
||||
);
|
||||
expect(Number((financeiroAntes.body as { total_a_receber: string }).total_a_receber)).toBe(300);
|
||||
|
||||
// 8) marca a primeira parcela como paga
|
||||
const primeiraParcela = vendaBody.parcelas[0];
|
||||
await asTenant(tenantToken)
|
||||
.patch(`/tenant/recebimentos/${primeiraParcela.id}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'pix' })
|
||||
.expect(200);
|
||||
|
||||
// 9) dashboard financeiro reflete o pagamento
|
||||
const financeiroDepois = await asTenant(tenantToken)
|
||||
.get('/tenant/dashboard/financeiro')
|
||||
.expect(200);
|
||||
const depois = financeiroDepois.body as {
|
||||
total_recebido_mes: string;
|
||||
total_a_receber: string;
|
||||
proximas_parcelas: Array<{ id: string }>;
|
||||
};
|
||||
expect(Number(depois.total_recebido_mes)).toBeCloseTo(Number(primeiraParcela.valor), 2);
|
||||
expect(Number(depois.total_a_receber)).toBeCloseTo(300 - Number(primeiraParcela.valor), 2);
|
||||
expect(depois.proximas_parcelas.some((p) => p.id === primeiraParcela.id)).toBe(false);
|
||||
|
||||
// 10) a venda em si também reflete a parcela paga
|
||||
const vendaDetalhe = await asTenant(tenantToken)
|
||||
.get(`/tenant/vendas/${vendaBody.id}`)
|
||||
.expect(200);
|
||||
const parcelasDetalhe = (
|
||||
vendaDetalhe.body as { parcelas: Array<{ id: string; status: string }> }
|
||||
).parcelas;
|
||||
expect(parcelasDetalhe.find((p) => p.id === primeiraParcela.id)?.status).toBe('pago');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// PNG 1x1 mínimo válido (base64), só pra exercitar o upload de verdade.
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
describe('Perfil do Tenant e Personalização Whitelabel (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-etapa8-${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}`),
|
||||
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() {
|
||||
const res = await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({ nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`, valor_mensal: 199.9 })
|
||||
.expect(201);
|
||||
planoIds.push((res.body as { id: string }).id);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
async function criarTenantComAdmin() {
|
||||
const plano = await criarPlano();
|
||||
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
|
||||
const res = await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: `Tenant Etapa8 ${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, 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 8', 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/perfil', () => {
|
||||
it('retorna o perfil sem logo/cor definidos inicialmente', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const res = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
expect(res.body).toMatchObject({ logo_url: null, cor_primaria: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST/DELETE /tenant/perfil/logo', () => {
|
||||
it('faz upload de um PNG válido e reflete em GET /tenant/perfil', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
const upload = await request(httpServer)
|
||||
.post('/tenant/perfil/logo')
|
||||
.set('Authorization', `Bearer ${tenant.token}`)
|
||||
.attach('logo', PNG_1X1, { filename: 'logo.png', contentType: 'image/png' })
|
||||
.expect(201);
|
||||
|
||||
const logoUrl = (upload.body as { logo_url: string }).logo_url;
|
||||
expect(logoUrl).toMatch(/^\/uploads\/.+\.png$/);
|
||||
|
||||
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
expect((perfil.body as { logo_url: string }).logo_url).toBe(logoUrl);
|
||||
|
||||
// servido como arquivo estático público, sem autenticação
|
||||
await request(httpServer).get(logoUrl).expect(200);
|
||||
});
|
||||
|
||||
it('rejeita arquivo maior que 2MB', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
const arquivoGrande = Buffer.alloc(3 * 1024 * 1024, 1);
|
||||
|
||||
await request(httpServer)
|
||||
.post('/tenant/perfil/logo')
|
||||
.set('Authorization', `Bearer ${tenant.token}`)
|
||||
.attach('logo', arquivoGrande, { filename: 'logo.png', contentType: 'image/png' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejeita tipo de arquivo não suportado', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await request(httpServer)
|
||||
.post('/tenant/perfil/logo')
|
||||
.set('Authorization', `Bearer ${tenant.token}`)
|
||||
.attach('logo', Buffer.from('não é uma imagem'), {
|
||||
filename: 'arquivo.txt',
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('DELETE remove a logo e volta ao padrão (logo_url null)', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await request(httpServer)
|
||||
.post('/tenant/perfil/logo')
|
||||
.set('Authorization', `Bearer ${tenant.token}`)
|
||||
.attach('logo', PNG_1X1, { filename: 'logo.png', contentType: 'image/png' })
|
||||
.expect(201);
|
||||
|
||||
await asTenant(tenant.token).delete('/tenant/perfil/logo').expect(200);
|
||||
|
||||
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
expect((perfil.body as { logo_url: string | null }).logo_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tenant/perfil/cor', () => {
|
||||
it('atualiza a cor primária em formato hex válido', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.put('/tenant/perfil/cor')
|
||||
.send({ cor_primaria: '#1A73E8' })
|
||||
.expect(200);
|
||||
|
||||
const perfil = await asTenant(tenant.token).get('/tenant/perfil').expect(200);
|
||||
expect((perfil.body as { cor_primaria: string }).cor_primaria).toBe('#1A73E8');
|
||||
});
|
||||
|
||||
it('rejeita cor fora do formato hex #RRGGBB', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.put('/tenant/perfil/cor')
|
||||
.send({ cor_primaria: 'azul' })
|
||||
.expect(400);
|
||||
|
||||
await asTenant(tenant.token)
|
||||
.put('/tenant/perfil/cor')
|
||||
.send({ cor_primaria: '#FFF' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /public/tenants/:id/tema — sem autenticação', () => {
|
||||
it('retorna nome/logo/cor sem token', async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
await asTenant(tenant.token)
|
||||
.put('/tenant/perfil/cor')
|
||||
.send({ cor_primaria: '#00FF00' })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(httpServer)
|
||||
.get(`/public/tenants/${tenant.tenantId}/tema`)
|
||||
.expect(200);
|
||||
expect(res.body).toMatchObject({ cor_primaria: '#00FF00' });
|
||||
expect((res.body as { nome: string }).nome).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('tenant inexistente retorna 404', async () => {
|
||||
await request(httpServer).get(`/public/tenants/${randomUUID()}/tema`).expect(404);
|
||||
});
|
||||
|
||||
it('id mal formado retorna 400', async () => {
|
||||
await request(httpServer).get('/public/tenants/not-a-uuid/tema').expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controle de acesso', () => {
|
||||
it('master "puro" recebe 403 em /tenant/perfil', async () => {
|
||||
await asMaster().get('/tenant/perfil').expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cabeçalhos de segurança (Helmet) e CORS', () => {
|
||||
it('Helmet adiciona cabeçalhos de segurança padrão', async () => {
|
||||
const res = await request(httpServer).get('/health').expect(200);
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('CORS libera só origem configurada em CORS_ORIGINS', async () => {
|
||||
const permitida = await request(httpServer)
|
||||
.get('/health')
|
||||
.set('Origin', 'http://localhost:5173')
|
||||
.expect(200);
|
||||
expect(permitida.headers['access-control-allow-origin']).toBe('http://localhost:5173');
|
||||
|
||||
const naoPermitida = await request(httpServer)
|
||||
.get('/health')
|
||||
.set('Origin', 'http://origem-nao-autorizada.com')
|
||||
.expect(200);
|
||||
expect(naoPermitida.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user