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[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 { 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(); 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(); }); }); });