Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* roleNormalizer.spec.js
|
||||
*
|
||||
* Função pura — zero mock. Garante contrato único entre guards.js e
|
||||
* tenantStore.js (evita regressão do V#4 resolvido).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeRole } from '../roleNormalizer.js';
|
||||
|
||||
describe('normalizeRole — tenant_admin/admin + kind', () => {
|
||||
it('tenant_admin + kind=therapist → therapist', () => {
|
||||
expect(normalizeRole('tenant_admin', 'therapist')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=saas → therapist (saas é alias de therapist)', () => {
|
||||
expect(normalizeRole('tenant_admin', 'saas')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=supervisor → supervisor', () => {
|
||||
expect(normalizeRole('tenant_admin', 'supervisor')).toBe('supervisor');
|
||||
});
|
||||
|
||||
it('tenant_admin + kind=clinic → clinic_admin (fallback)', () => {
|
||||
expect(normalizeRole('tenant_admin', 'clinic')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('tenant_admin sem kind → clinic_admin (legado)', () => {
|
||||
expect(normalizeRole('tenant_admin', null)).toBe('clinic_admin');
|
||||
expect(normalizeRole('tenant_admin', '')).toBe('clinic_admin');
|
||||
expect(normalizeRole('tenant_admin')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('admin (alias legado) segue mesmas regras de tenant_admin', () => {
|
||||
expect(normalizeRole('admin', 'therapist')).toBe('therapist');
|
||||
expect(normalizeRole('admin', 'supervisor')).toBe('supervisor');
|
||||
expect(normalizeRole('admin', null)).toBe('clinic_admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRole — pass-through', () => {
|
||||
it('clinic_admin canônico → clinic_admin', () => {
|
||||
expect(normalizeRole('clinic_admin')).toBe('clinic_admin');
|
||||
expect(normalizeRole('clinic_admin', 'qualquer')).toBe('clinic_admin');
|
||||
});
|
||||
|
||||
it('therapist explícito → therapist', () => {
|
||||
expect(normalizeRole('therapist')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('supervisor explícito → supervisor', () => {
|
||||
expect(normalizeRole('supervisor')).toBe('supervisor');
|
||||
});
|
||||
|
||||
it('roles não mapeados retornam intactos', () => {
|
||||
expect(normalizeRole('saas_admin')).toBe('saas_admin');
|
||||
expect(normalizeRole('portal_user')).toBe('portal_user');
|
||||
expect(normalizeRole('editor')).toBe('editor');
|
||||
expect(normalizeRole('patient')).toBe('patient');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRole — entradas inválidas', () => {
|
||||
it('null/undefined retornam string vazia', () => {
|
||||
expect(normalizeRole(null)).toBe('');
|
||||
expect(normalizeRole(undefined)).toBe('');
|
||||
expect(normalizeRole()).toBe('');
|
||||
});
|
||||
|
||||
it('string vazia retorna vazia', () => {
|
||||
expect(normalizeRole('')).toBe('');
|
||||
expect(normalizeRole(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('trim é aplicado', () => {
|
||||
expect(normalizeRole(' therapist ')).toBe('therapist');
|
||||
expect(normalizeRole(' tenant_admin ', ' therapist ')).toBe('therapist');
|
||||
});
|
||||
|
||||
it('números são coercidos para string', () => {
|
||||
// Não deveria acontecer em produção, mas não quebra
|
||||
expect(normalizeRole(123)).toBe('123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* validators.spec.js — T#7 (cobertura dos helpers de sanitização do external intake)
|
||||
*
|
||||
* O módulo validators.js é a base de toda sanitização da página pública de
|
||||
* cadastro (CadastroPacienteExterno) e de várias outras páginas internas.
|
||||
* Esses testes garantem o contrato: o backend (RPC create_patient_intake_request_v2)
|
||||
* confia que os dígitos vêm normalizados, datas em ISO, emails sem espaço, etc.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
digitsOnly,
|
||||
isValidCPF,
|
||||
fmtCPF,
|
||||
generateCPF,
|
||||
isValidCNPJ,
|
||||
fmtCNPJ,
|
||||
fmtRG,
|
||||
isValidPhone,
|
||||
fmtPhone,
|
||||
isValidEmail,
|
||||
isValidCEP,
|
||||
fmtCEP,
|
||||
sanitizeDigits,
|
||||
toISODate
|
||||
} from '../validators.js';
|
||||
|
||||
describe('digitsOnly — base de toda sanitização', () => {
|
||||
it('extrai só dígitos de strings com máscara', () => {
|
||||
expect(digitsOnly('(11) 98765-4321')).toBe('11987654321');
|
||||
expect(digitsOnly('123.456.789-01')).toBe('12345678901');
|
||||
expect(digitsOnly('00000-000')).toBe('00000000');
|
||||
});
|
||||
|
||||
it('retorna string vazia para null/undefined/vazio', () => {
|
||||
expect(digitsOnly(null)).toBe('');
|
||||
expect(digitsOnly(undefined)).toBe('');
|
||||
expect(digitsOnly('')).toBe('');
|
||||
expect(digitsOnly(' ')).toBe('');
|
||||
expect(digitsOnly('abc')).toBe('');
|
||||
});
|
||||
|
||||
it('preserva apenas os dígitos em qualquer string', () => {
|
||||
expect(digitsOnly('a1b2c3')).toBe('123');
|
||||
expect(digitsOnly('R$ 1.234,56')).toBe('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCPF — algoritmo completo', () => {
|
||||
it('aceita CPFs válidos com e sem máscara', () => {
|
||||
expect(isValidCPF('529.982.247-25')).toBe(true);
|
||||
expect(isValidCPF('52998224725')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita comprimento incorreto', () => {
|
||||
expect(isValidCPF('123')).toBe(false);
|
||||
expect(isValidCPF('12345678901234')).toBe(false);
|
||||
expect(isValidCPF('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita sequências repetidas (regressão fraude clássica)', () => {
|
||||
expect(isValidCPF('11111111111')).toBe(false);
|
||||
expect(isValidCPF('00000000000')).toBe(false);
|
||||
expect(isValidCPF('99999999999')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita dígitos verificadores inválidos', () => {
|
||||
expect(isValidCPF('52998224726')).toBe(false); // último dígito errado
|
||||
expect(isValidCPF('52998224715')).toBe(false); // penúltimo dígito errado
|
||||
});
|
||||
|
||||
it('aceita CPF gerado por generateCPF (round-trip)', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const gen = generateCPF();
|
||||
expect(isValidCPF(gen)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCPF — formatação visual', () => {
|
||||
it('formata 11 dígitos como 000.000.000-00', () => {
|
||||
expect(fmtCPF('52998224725')).toBe('529.982.247-25');
|
||||
});
|
||||
|
||||
it('aceita parcial e formata até onde tem', () => {
|
||||
expect(fmtCPF('123')).toBe('123');
|
||||
expect(fmtCPF('1234')).toBe('123.4');
|
||||
expect(fmtCPF('123456')).toBe('123.456');
|
||||
expect(fmtCPF('1234567')).toBe('123.456.7');
|
||||
});
|
||||
|
||||
it('trunca em 11 dígitos', () => {
|
||||
expect(fmtCPF('5299822472512345')).toBe('529.982.247-25');
|
||||
});
|
||||
|
||||
it('vazio para null/empty', () => {
|
||||
expect(fmtCPF('')).toBe('');
|
||||
expect(fmtCPF(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCNPJ — algoritmo completo', () => {
|
||||
it('aceita CNPJs válidos com e sem máscara', () => {
|
||||
expect(isValidCNPJ('11.222.333/0001-81')).toBe(true);
|
||||
expect(isValidCNPJ('11222333000181')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita sequências repetidas', () => {
|
||||
expect(isValidCNPJ('11111111111111')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita comprimento incorreto', () => {
|
||||
expect(isValidCNPJ('123')).toBe(false);
|
||||
expect(isValidCNPJ('11.222.333/0001-')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCNPJ — formatação visual', () => {
|
||||
it('formata 14 dígitos como 00.000.000/0000-00', () => {
|
||||
expect(fmtCNPJ('11222333000181')).toBe('11.222.333/0001-81');
|
||||
});
|
||||
|
||||
it('vazio para input vazio', () => {
|
||||
expect(fmtCNPJ('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtRG — formatação visual', () => {
|
||||
it('formata 9 dígitos', () => {
|
||||
expect(fmtRG('123456789')).toBe('12.345.678-9');
|
||||
});
|
||||
|
||||
it('vazio para vazio/null', () => {
|
||||
expect(fmtRG('')).toBe('');
|
||||
expect(fmtRG(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidPhone — telefones BR', () => {
|
||||
it('aceita 10 dígitos (fixo) e 11 (celular)', () => {
|
||||
expect(isValidPhone('1133334444')).toBe(true); // fixo
|
||||
expect(isValidPhone('11933334444')).toBe(true); // celular
|
||||
expect(isValidPhone('(11) 9 3333-4444')).toBe(true); // com máscara
|
||||
});
|
||||
|
||||
it('rejeita comprimentos errados', () => {
|
||||
expect(isValidPhone('123')).toBe(false);
|
||||
expect(isValidPhone('123456789012')).toBe(false);
|
||||
expect(isValidPhone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtPhone — formatação visual', () => {
|
||||
it('formata celular (11 dígitos) como (XX) XXXXX-XXXX', () => {
|
||||
expect(fmtPhone('11987654321')).toBe('(11) 98765-4321');
|
||||
});
|
||||
|
||||
it('formata fixo (10 dígitos) como (XX) XXXX-XXXX', () => {
|
||||
expect(fmtPhone('1133334444')).toBe('(11) 3333-4444');
|
||||
});
|
||||
|
||||
it('retorna dígitos crus para tamanhos não suportados', () => {
|
||||
expect(fmtPhone('123')).toBe('123');
|
||||
});
|
||||
|
||||
it('vazio para vazio', () => {
|
||||
expect(fmtPhone('')).toBe('');
|
||||
expect(fmtPhone(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail — formato mínimo', () => {
|
||||
it('aceita emails com formato razoável', () => {
|
||||
expect(isValidEmail('user@test.com')).toBe(true);
|
||||
expect(isValidEmail('a@b.c')).toBe(true);
|
||||
expect(isValidEmail(' user@test.com ')).toBe(true); // trim aplicado
|
||||
});
|
||||
|
||||
it('rejeita vazios e null', () => {
|
||||
expect(isValidEmail('')).toBe(false);
|
||||
expect(isValidEmail(null)).toBe(false);
|
||||
expect(isValidEmail(undefined)).toBe(false);
|
||||
expect(isValidEmail(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita formatos sem @ ou sem domínio', () => {
|
||||
expect(isValidEmail('user')).toBe(false);
|
||||
expect(isValidEmail('user@')).toBe(false);
|
||||
expect(isValidEmail('@host.com')).toBe(false);
|
||||
expect(isValidEmail('user@host')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCEP — 8 dígitos', () => {
|
||||
it('aceita CEP de 8 dígitos com ou sem máscara', () => {
|
||||
expect(isValidCEP('01310-100')).toBe(true);
|
||||
expect(isValidCEP('01310100')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita comprimentos errados', () => {
|
||||
expect(isValidCEP('123')).toBe(false);
|
||||
expect(isValidCEP('12345-67')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtCEP — formatação visual', () => {
|
||||
it('formata 8 dígitos como 00000-000', () => {
|
||||
expect(fmtCEP('01310100')).toBe('01310-100');
|
||||
});
|
||||
|
||||
it('parcial mantém prefixo formatado', () => {
|
||||
expect(fmtCEP('01310')).toBe('01310');
|
||||
expect(fmtCEP('013101')).toBe('01310-1');
|
||||
});
|
||||
|
||||
it('trunca em 8', () => {
|
||||
expect(fmtCEP('0131010012345')).toBe('01310-100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDigits — preparação para o banco', () => {
|
||||
it('extrai dígitos ou retorna null para vazio', () => {
|
||||
expect(sanitizeDigits('(11) 98765-4321')).toBe('11987654321');
|
||||
expect(sanitizeDigits('')).toBe(null);
|
||||
expect(sanitizeDigits(null)).toBe(null);
|
||||
expect(sanitizeDigits('abc')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toISODate — DD/MM/YYYY ou DD-MM-YYYY → YYYY-MM-DD', () => {
|
||||
it('converte DD/MM/YYYY → ISO', () => {
|
||||
expect(toISODate('15/03/1990')).toBe('1990-03-15');
|
||||
});
|
||||
|
||||
it('converte DD-MM-YYYY → ISO', () => {
|
||||
expect(toISODate('15-03-1990')).toBe('1990-03-15');
|
||||
});
|
||||
|
||||
it('retorna null para formato inválido', () => {
|
||||
expect(toISODate('1990-03-15')).toBe(null); // já em ISO, mas formato esperado é DD/MM/YYYY
|
||||
expect(toISODate('15/3/1990')).toBe(null); // mês com 1 dígito
|
||||
expect(toISODate('abc')).toBe(null);
|
||||
expect(toISODate('')).toBe(null);
|
||||
expect(toISODate(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('rejeita data inexistente (mes 13)', () => {
|
||||
// Date constructor faz wrap silencioso; testamos comportamento atual.
|
||||
// 13/13/2020 → Date('2020-13-13') é Invalid Date → null
|
||||
expect(toISODate('13/13/2020')).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/utils/roleNormalizer.js
|
||||
|
|
||||
| Normalização única de roles de tenant. Antes existia em guards.js e
|
||||
| tenantStore.js duplicada — se uma mudasse sem a outra, tenant e guard
|
||||
| divergiam sobre o role normalizado (bug sutil de permissão).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normaliza o role a partir do role cru do tenant_members + kind do tenant.
|
||||
*
|
||||
* Regras:
|
||||
* tenant_admin / admin + kind = 'therapist' | 'saas' → 'therapist'
|
||||
* tenant_admin / admin + kind = 'supervisor' → 'supervisor'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
||||
* 'clinic_admin' (role canônico) → 'clinic_admin'
|
||||
* qualquer outro role → pass-through
|
||||
*
|
||||
* @param {string|null|undefined} role Role cru (tenant_members.role ou profiles.role).
|
||||
* @param {string|null|undefined} kind Kind do tenant (tenants.kind).
|
||||
* @returns {string} role normalizado (sempre string — '' quando sem input).
|
||||
*/
|
||||
export function normalizeRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return '';
|
||||
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
if (r === 'clinic_admin') return 'clinic_admin';
|
||||
|
||||
return r;
|
||||
}
|
||||
Reference in New Issue
Block a user