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>
150 lines
6.1 KiB
JavaScript
150 lines
6.1 KiB
JavaScript
// =============================================================================
|
|
// T#10 — Golden path: paciente abre link → preenche → submit → sucesso
|
|
// =============================================================================
|
|
// Strategy: intercepta as chamadas pra Supabase Functions/REST e responde
|
|
// com mocks. Sem precisar de banco real ou edge functions rodando.
|
|
//
|
|
// Cobre as camadas de defesa em camadas (A#20 rev2):
|
|
// - honeypot (campo invisível, garantimos que não preenchemos)
|
|
// - submit normal funciona
|
|
// - 403 captcha-required → componente MathCaptchaChallenge aparece
|
|
// - 429 rate-limited → toast amigável
|
|
// - token UUID inválido bloqueia a página
|
|
// =============================================================================
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; // UUID v4 válido (TOKEN_RX bate)
|
|
const PUBLIC_URL = `/cadastro/paciente?t=${VALID_TOKEN}`;
|
|
|
|
const FUNCTIONS_RX = /\/functions\/v1\/submit-patient-intake/;
|
|
|
|
// Helper: preenche os 3 campos obrigatórios + consent
|
|
async function fillRequired(page, { nome, email, telefone }) {
|
|
await page.locator('#f_nome').fill(nome);
|
|
await page.locator('#f_email_principal').fill(email);
|
|
await page.locator('#f_telefone').fill(telefone);
|
|
// PrimeVue Checkbox é um div com role=checkbox; .check() não funciona, precisa click
|
|
await page.locator('label[for="ext_consent"]').click();
|
|
}
|
|
|
|
test.describe('Cadastro paciente externo — golden path', () => {
|
|
test('paciente preenche e submete com sucesso (sem captcha)', async ({ page }) => {
|
|
await page.route(FUNCTIONS_RX, async (route) => {
|
|
const body = route.request().postDataJSON();
|
|
|
|
// honeypot NÃO foi preenchido (humano)
|
|
expect(body?.website).toBeFalsy();
|
|
expect(body?.token).toBe(VALID_TOKEN);
|
|
expect(body?.payload?.nome_completo).toBe('Maria da Silva');
|
|
expect(body?.payload?.consent).toBe(true);
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true, intake_id: 'fake-intake-uuid' })
|
|
});
|
|
});
|
|
|
|
await page.goto(PUBLIC_URL);
|
|
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
|
|
|
await fillRequired(page, {
|
|
nome: 'Maria da Silva',
|
|
email: 'maria@test.com',
|
|
telefone: '11987654321'
|
|
});
|
|
|
|
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
|
await expect(page.getByText(/Enviado com sucesso/i).first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('rate limit (429) mostra mensagem amigável de tentativas', async ({ page }) => {
|
|
await page.route(FUNCTIONS_RX, async (route) => {
|
|
await route.fulfill({
|
|
status: 429,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'rate-limited', retry_after_seconds: 600 })
|
|
});
|
|
});
|
|
|
|
await page.goto(PUBLIC_URL);
|
|
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
|
|
|
await fillRequired(page, {
|
|
nome: 'João Bot',
|
|
email: 'joao@test.com',
|
|
telefone: '11999999999'
|
|
});
|
|
|
|
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
|
await expect(page.getByText(/Muitas tentativas/i)).toBeVisible({ timeout: 6_000 });
|
|
});
|
|
|
|
test('captcha-required mostra MathCaptchaChallenge e bloqueia botão até resposta', async ({ page }) => {
|
|
let firstSubmitCall = true;
|
|
|
|
await page.route(FUNCTIONS_RX, async (route) => {
|
|
const url = route.request().url();
|
|
if (url.includes('/captcha-challenge')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ challenge: { id: 'fake-challenge-id', question: 'Quanto é 2 + 3?' } })
|
|
});
|
|
return;
|
|
}
|
|
if (firstSubmitCall) {
|
|
firstSubmitCall = false;
|
|
await route.fulfill({
|
|
status: 403,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'captcha-required' })
|
|
});
|
|
return;
|
|
}
|
|
// segunda call: ok
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true, intake_id: 'after-captcha' })
|
|
});
|
|
});
|
|
|
|
await page.goto(PUBLIC_URL);
|
|
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
|
|
|
await fillRequired(page, {
|
|
nome: 'Ana Pereira',
|
|
email: 'ana@test.com',
|
|
telefone: '11955554444'
|
|
});
|
|
|
|
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
|
|
|
// MathCaptchaChallenge aparece
|
|
await expect(page.getByText('Quanto é 2 + 3?')).toBeVisible({ timeout: 8_000 });
|
|
|
|
// Botão "Enviar cadastro" do footer fica disabled enquanto sem resposta
|
|
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toBeDisabled();
|
|
});
|
|
|
|
test('honeypot field existe no DOM mas está fora da viewport', async ({ page }) => {
|
|
await page.goto(PUBLIC_URL);
|
|
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
|
|
|
const honeypot = page.locator('input#ext_website');
|
|
await expect(honeypot).toHaveCount(1);
|
|
await expect(honeypot).not.toBeInViewport();
|
|
});
|
|
|
|
test('token inválido bloqueia toda a página de cadastro', async ({ page }) => {
|
|
await page.goto('/cadastro/paciente?t=token-invalido');
|
|
|
|
// Mensagem de erro aparece
|
|
await expect(page.getByText(/Link inválido ou ausente/i)).toBeVisible();
|
|
|
|
// Form não renderiza (botão Enviar não existe)
|
|
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toHaveCount(0);
|
|
});
|
|
});
|