#!/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);