Files
agenciapsilmno/database-novo/tests/run.cjs
T
Leonardo 7c20b518d4 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>
2026-04-19 15:42:46 -03:00

406 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);