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,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);
|
||||
Reference in New Issue
Block a user