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:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — RPC integration tests (T#8)
// =============================================================================
// Executa cenários SQL via `docker exec` no container do Postgres.
// Cada cenário roda em transação isolada (BEGIN ... ROLLBACK), zero side effect.
//
// Uso: node database-novo/tests/run.cjs
//
// Estrutura de cada caso:
// { name, sub: 'as user|as saas|anon', sql, expect: { ok|errorIncludes, jsonHas } }
// =============================================================================
const { execSync } = require('child_process');
const path = require('path');
const CONFIG = JSON.parse(require('fs').readFileSync(path.join(__dirname, '..', 'db.config.json'), 'utf8'));
const CONTAINER = CONFIG.container;
const DB = CONFIG.database;
const USER = CONFIG.user;
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// -----------------------------------------------------------------------------
function runSql(sql) {
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -v ON_ERROR_STOP=1 -t -A`;
try {
const out = execSync(cmd, { input: sql, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return { ok: true, out: String(out).trim(), err: null };
} catch (e) {
const stderr = String(e?.stderr || e?.message || '').trim();
return { ok: false, out: null, err: stderr };
}
}
function asUser(uid) {
return `SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claim.sub', '${uid}', true);`;
}
// IDs do seed que sabemos existir (db.config.json + seeds)
const SAAS_ADMIN_UID = 'aaaaaaaa-0006-0006-0006-000000000006';
const TENANT_ADMIN_UID = 'aaaaaaaa-0005-0005-0005-000000000005'; // owner de Clínica Bem Estar
const TENANT_BEM_ESTAR = 'bbbbbbbb-0005-0005-0005-000000000005';
const PATIENT_UID = 'aaaaaaaa-0001-0001-0001-000000000001';
// ─────────────────────────────────────────────────────────────────────────
// Cases
// -----------------------------------------------------------------------------
const cases = [
// ────── set_tenant_feature_exception ──────
{
name: 'set_tenant_feature_exception: anônimo é rejeitado',
sql: `BEGIN;
SET LOCAL ROLE authenticated;
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'patients', true, NULL);
ROLLBACK;`,
expect: { errorIncludes: 'Não autenticado' }
},
{
name: 'set_tenant_feature_exception: tenant_admin tenta override+ fora do plano → rejeitado',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, 'tentativa');
ROLLBACK;`,
expect: { errorIncludes: 'saas_admin' }
},
{
name: 'set_tenant_feature_exception: saas_admin sem reason em exceção → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, NULL);
ROLLBACK;`,
expect: { errorIncludes: 'reason' }
},
{
name: 'set_tenant_feature_exception: saas_admin com reason curto (<4 chars) — RPC aceita, frontend valida',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT (public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, 'PIX'))->>'is_exception';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'true' } // is_exception=true
},
{
name: 'set_tenant_feature_exception: tenant_admin desliga feature DO plano → permitido',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT (public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'patients', false, 'pref'))->>'plan_allows';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'true' }
},
{
name: 'set_tenant_feature_exception: feature_key inválida (uppercase) → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'PATIENTS', false, NULL);
ROLLBACK;`,
expect: { errorIncludes: 'formato inválido' }
},
{
name: 'set_tenant_feature_exception: feature_key desconhecida → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'unknown_feature', false, NULL);
ROLLBACK;`,
expect: { errorIncludes: 'desconhecida' }
},
{
name: 'set_tenant_feature_exception: tenant inexistente → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.set_tenant_feature_exception('99999999-9999-9999-9999-999999999999'::uuid, 'patients', false, NULL);
ROLLBACK;`,
expect: { errorIncludes: 'tenant não encontrado' }
},
{
name: 'set_tenant_feature_exception: trigger guard ainda bloqueia INSERT direto fora do plano',
sql: `BEGIN;
INSERT INTO tenant_features (tenant_id, feature_key, enabled)
VALUES ('${TENANT_BEM_ESTAR}', 'documents.signatures', true);
ROLLBACK;`,
expect: { errorIncludes: 'não permitida pelo plano' }
},
// ────── delete_plan_safe ──────
{
name: 'delete_plan_safe: anônimo é rejeitado',
sql: `BEGIN;
SET LOCAL ROLE authenticated;
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
ROLLBACK;`,
expect: { errorIncludes: 'Não autenticado' }
},
{
name: 'delete_plan_safe: tenant_admin não-saas é rejeitado',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
ROLLBACK;`,
expect: { errorIncludes: 'saas_admin' }
},
{
name: 'delete_plan_safe: bloqueia delete de plano com subscriptions ativas',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
ROLLBACK;`,
expect: { errorIncludes: 'assinatura' }
},
{
name: 'delete_plan_safe: plan_id null → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.delete_plan_safe(NULL);
ROLLBACK;`,
expect: { errorIncludes: 'plan_id obrigatório' }
},
{
name: 'delete_plan_safe: plano inexistente → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.delete_plan_safe('99999999-9999-9999-9999-999999999999'::uuid);
ROLLBACK;`,
expect: { errorIncludes: 'plano não encontrado' }
},
// ────── create_patient_intake_request_v2 ──────
{
name: 'create_patient_intake_request_v2: A#20 — anon NÃO chama mais o RPC direto',
sql: `BEGIN;
SET LOCAL ROLE anon;
SELECT public.create_patient_intake_request_v2('token-inexistente'::text, '{}'::jsonb, NULL::text);
ROLLBACK;`,
expect: { errorIncludes: 'permission denied' }
},
{
name: 'create_patient_intake_request_v2: token inválido (via authenticated) → Token inválido',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT public.create_patient_intake_request_v2('token-inexistente'::text, '{}'::jsonb, NULL::text);
ROLLBACK;`,
expect: { errorIncludes: 'Token inválido' }
},
{
name: 'create_patient_intake_request_v2: payload sem nome_completo → rejeitado',
sql: `BEGIN;
-- desativa invites pré-existentes desse owner (constraint one_active_per_owner)
UPDATE patient_invites SET active=false WHERE owner_id='${TENANT_ADMIN_UID}' AND active=true;
WITH inv AS (
INSERT INTO patient_invites (token, owner_id, tenant_id, active)
VALUES ('test-token-' || md5(random()::text), '${TENANT_ADMIN_UID}', '${TENANT_BEM_ESTAR}', true)
RETURNING token
)
SELECT public.create_patient_intake_request_v2(
(SELECT token FROM inv),
jsonb_build_object('email_principal','test@x.com','telefone','11999999999','consent',true),
NULL::text
);
ROLLBACK;`,
expect: { errorIncludes: 'Nome' }
},
// ────── features.is_active (V#40 sanity) ──────
{
name: 'features.is_active existe e default true',
sql: `SELECT column_default FROM information_schema.columns WHERE table_name='features' AND column_name='is_active';`,
expect: { ok: true, jsonHas: 'true' }
},
// ────── A#20 rev2: defesa em camadas ──────
{
name: 'check_rate_limit: IP novo → allowed=true',
sql: `BEGIN;
SET LOCAL ROLE service_role;
SELECT (public.check_rate_limit('test-hash-novo-' || gen_random_uuid()::text, 'patient_intake'))->>'allowed';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'true' }
},
{
name: 'record_submission_attempt: 3 falhas seguidas → marca requires_captcha',
sql: `BEGIN;
SET LOCAL ROLE service_role;
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT (public.check_rate_limit('h-fail-3', 'patient_intake'))->>'requires_captcha';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'true' }
},
{
name: 'record_submission_attempt: > max_attempts → bloqueia (allowed=false)',
sql: `BEGIN;
SET LOCAL ROLE service_role;
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
SELECT (public.check_rate_limit('h-block', 'patient_intake'))->>'allowed';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'false' }
},
{
name: 'generate_math_challenge: cria id + question',
sql: `BEGIN;
SET LOCAL ROLE service_role;
SELECT (public.generate_math_challenge())->>'question';
ROLLBACK;`,
expect: { ok: true, jsonHas: 'Quanto' }
},
{
name: 'verify_math_challenge: id desconhecido → false',
sql: `BEGIN;
SET LOCAL ROLE service_role;
SELECT public.verify_math_challenge('00000000-0000-0000-0000-000000000000'::uuid, 1)::text;
ROLLBACK;`,
expect: { ok: true, jsonHas: 'f' }
},
{
name: 'check_rate_limit: anon não pode chamar (só service_role)',
sql: `BEGIN;
SET LOCAL ROLE anon;
SELECT public.check_rate_limit('h', 'patient_intake');
ROLLBACK;`,
expect: { errorIncludes: 'permission denied' }
},
{
name: 'saas_security_config singleton existe com defaults',
sql: `SELECT honeypot_enabled::text || ',' || rate_limit_enabled::text FROM saas_security_config WHERE id=true;`,
expect: { ok: true, jsonHas: 'true,true' }
},
// ────── saas_twilio_config + RPCs ──────
{
name: 'get_twilio_config: anon NÃO pode chamar',
sql: `BEGIN;
SET LOCAL ROLE anon;
SELECT public.get_twilio_config();
ROLLBACK;`,
expect: { errorIncludes: 'permission denied' }
},
{
name: 'get_twilio_config: authenticated não-saas → "Sem permissão"',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT public.get_twilio_config();
ROLLBACK;`,
expect: { errorIncludes: 'Sem permissão' }
},
{
name: 'get_twilio_config: saas_admin retorna defaults',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT (public.get_twilio_config())->>'usd_brl_rate';
ROLLBACK;`,
expect: { ok: true, jsonHas: '5.5' }
},
{
name: 'update_twilio_config: tenant_admin é rejeitado',
sql: `BEGIN;
${asUser(TENANT_ADMIN_UID)}
SELECT public.update_twilio_config(p_usd_brl_rate := 6);
ROLLBACK;`,
expect: { errorIncludes: 'saas_admin' }
},
{
name: 'update_twilio_config: SID inválido (sem AC) → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.update_twilio_config(p_account_sid := 'XX123');
ROLLBACK;`,
expect: { errorIncludes: 'account_sid inválido' }
},
{
name: 'update_twilio_config: webhook sem http(s) → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.update_twilio_config(p_whatsapp_webhook_url := 'ftp://x.com');
ROLLBACK;`,
expect: { errorIncludes: 'webhook' }
},
{
name: 'update_twilio_config: rate fora da faixa → rejeitado',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT public.update_twilio_config(p_usd_brl_rate := 200);
ROLLBACK;`,
expect: { errorIncludes: 'usd_brl_rate' }
},
{
name: 'update_twilio_config: saas_admin com payload válido → ok',
sql: `BEGIN;
${asUser(SAAS_ADMIN_UID)}
SELECT (public.update_twilio_config(
p_account_sid := 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
p_usd_brl_rate := 5.85,
p_margin_multiplier := 1.5
))->>'usd_brl_rate';
ROLLBACK;`,
expect: { ok: true, jsonHas: '5.85' }
}
];
// ─────────────────────────────────────────────────────────────────────────
// Runner
// -----------------------------------------------------------------------------
function color(s, c) {
const map = { red: 31, green: 32, yellow: 33, gray: 90 };
return `\x1b[${map[c] || 0}m${s}\x1b[0m`;
}
let pass = 0;
let fail = 0;
const failures = [];
console.log(color('▶ AgenciaPsi RPC integration tests', 'gray'));
console.log(color('───────────────────────────────────', 'gray'));
for (const c of cases) {
process.stdout.write(` ${c.name} ... `);
const r = runSql(c.sql);
let ok = true;
let why = '';
if (c.expect.errorIncludes) {
if (r.ok) {
ok = false;
why = `esperava erro com "${c.expect.errorIncludes}" mas SQL passou. saída=${r.out}`;
} else if (!r.err.includes(c.expect.errorIncludes)) {
ok = false;
why = `erro não contém "${c.expect.errorIncludes}". stderr=${r.err.slice(0, 200)}`;
}
} else {
if (!r.ok) {
ok = false;
why = `esperava sucesso mas falhou. stderr=${r.err.slice(0, 300)}`;
} else if (c.expect.jsonHas && !r.out.includes(c.expect.jsonHas)) {
ok = false;
why = `saída não contém "${c.expect.jsonHas}". out=${r.out}`;
}
}
if (ok) {
pass++;
console.log(color('✓', 'green'));
} else {
fail++;
failures.push({ name: c.name, why });
console.log(color('✗', 'red'));
}
}
console.log(color('───────────────────────────────────', 'gray'));
console.log(`${color(pass + ' passed', 'green')}, ${fail > 0 ? color(fail + ' failed', 'red') : color('0 failed', 'gray')}`);
if (failures.length) {
console.log('\n' + color('FAILURES:', 'red'));
for (const f of failures) {
console.log(` ${color('×', 'red')} ${f.name}`);
console.log(` ${color(f.why, 'yellow')}`);
}
process.exit(1);
}
process.exit(0);