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
@@ -0,0 +1,96 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI MathCaptchaChallenge (A#20 rev2)
|--------------------------------------------------------------------------
| Componente de captcha matemático invocado SOB DEMANDA quando a edge
| function retorna 403 captcha-required ou na primeira tentativa se o IP
| está marcado como suspeito.
|
| Uso:
| <MathCaptchaChallenge
| v-model:id="captchaId"
| v-model:answer="captchaAnswer"
| :function-url="..."
| />
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted } from 'vue';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import { supabase } from '@/lib/supabase/client';
const props = defineProps({
id: { type: String, default: '' },
answer: { type: [Number, null], default: null },
autoLoad: { type: Boolean, default: true }
});
const emit = defineEmits(['update:id', 'update:answer']);
const challenge = ref({ id: '', question: '' });
const loading = ref(false);
const error = ref('');
const localAnswer = ref(props.answer);
async function loadChallenge() {
loading.value = true;
error.value = '';
try {
const { data, error: fnErr } = await supabase.functions.invoke('submit-patient-intake/captcha-challenge', {
method: 'POST',
body: {}
});
if (fnErr) throw fnErr;
const ch = data?.challenge || data;
challenge.value = { id: ch?.id || '', question: ch?.question || '' };
emit('update:id', challenge.value.id);
localAnswer.value = null;
emit('update:answer', null);
} catch (e) {
error.value = e?.message || 'Não foi possível carregar a verificação.';
} finally {
loading.value = false;
}
}
function onAnswerChange(v) {
localAnswer.value = v;
emit('update:answer', v);
}
onMounted(() => {
if (props.autoLoad) loadChallenge();
});
defineExpose({ loadChallenge });
</script>
<template>
<div class="rounded-xl border border-amber-400/30 bg-amber-400/5 p-4">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="text-sm font-semibold text-slate-100">
<i class="pi pi-shield mr-2 text-amber-300" />
Verificação rápida
</div>
<Button icon="pi pi-refresh" text size="small" :loading="loading" @click="loadChallenge" v-tooltip.top="'Outra pergunta'" />
</div>
<p v-if="!challenge.question && !loading" class="text-xs text-slate-300">Carregando</p>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<div v-if="challenge.question" class="flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="text-base text-slate-100 font-medium">{{ challenge.question }}</span>
<InputNumber
:modelValue="localAnswer"
@update:modelValue="onAnswerChange"
placeholder="?"
class="!w-32"
:useGrouping="false"
inputClass="text-center"
/>
</div>
<p class="mt-2 text-[11px] text-slate-400">Confirma que você é humano. Sem cookies, sem rastreio externo.</p>
</div>
</template>