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:
2026-08-14 23:38:37 -03:00
co-authored by Claude Sonnet 5
parent d6d7d5fa61
commit f9738e54da
48 changed files with 1470 additions and 64 deletions
+288
View File
@@ -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();
});
});
});