7c20b518d4
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>
406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
#!/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);
|